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 base64
16import concurrent.futures
17import datetime
18import errno
19import inspect
20import io
21import logging
22import os
23import platform
24import random
25import re
26import shlex
27import signal
28import string
29import subprocess
30import threading
31import time
32import traceback
33from typing import Literal, Tuple, overload
34
35import portpicker
36
37# File name length is limited to 255 chars on some OS, so we need to make sure
38# the file names we output fits within the limit.
39MAX_FILENAME_LEN = 255
40# Number of times to retry to get available port
41MAX_PORT_ALLOCATION_RETRY = 50
42
43ascii_letters_and_digits = string.ascii_letters + string.digits
44valid_filename_chars = f'-_.{ascii_letters_and_digits}'
45
46GMT_to_olson = {
47    'GMT-9': 'America/Anchorage',
48    'GMT-8': 'US/Pacific',
49    'GMT-7': 'US/Mountain',
50    'GMT-6': 'US/Central',
51    'GMT-5': 'US/Eastern',
52    'GMT-4': 'America/Barbados',
53    'GMT-3': 'America/Buenos_Aires',
54    'GMT-2': 'Atlantic/South_Georgia',
55    'GMT-1': 'Atlantic/Azores',
56    'GMT+0': 'Africa/Casablanca',
57    'GMT+1': 'Europe/Amsterdam',
58    'GMT+2': 'Europe/Athens',
59    'GMT+3': 'Europe/Moscow',
60    'GMT+4': 'Asia/Baku',
61    'GMT+5': 'Asia/Oral',
62    'GMT+6': 'Asia/Almaty',
63    'GMT+7': 'Asia/Bangkok',
64    'GMT+8': 'Asia/Hong_Kong',
65    'GMT+9': 'Asia/Tokyo',
66    'GMT+10': 'Pacific/Guam',
67    'GMT+11': 'Pacific/Noumea',
68    'GMT+12': 'Pacific/Fiji',
69    'GMT+13': 'Pacific/Tongatapu',
70    'GMT-11': 'Pacific/Midway',
71    'GMT-10': 'Pacific/Honolulu',
72}
73
74
75class Error(Exception):
76  """Raised when an error occurs in a util"""
77
78
79def abs_path(path):
80  """Resolve the '.' and '~' in a path to get the absolute path.
81
82  Args:
83    path: The path to expand.
84
85  Returns:
86    The absolute path of the input path.
87  """
88  return os.path.abspath(os.path.expanduser(path))
89
90
91def create_dir(path):
92  """Creates a directory if it does not exist already.
93
94  Args:
95    path: The path of the directory to create.
96  """
97  full_path = abs_path(path)
98  if not os.path.exists(full_path):
99    try:
100      os.makedirs(full_path)
101    except OSError as e:
102      # ignore the error for dir already exist.
103      if e.errno != errno.EEXIST:
104        raise
105
106
107def create_alias(target_path, alias_path):
108  """Creates an alias at 'alias_path' pointing to the file 'target_path'.
109
110  On Unix, this is implemented via symlink. On Windows, this is done by
111  creating a Windows shortcut file.
112
113  Args:
114    target_path: Destination path that the alias should point to.
115    alias_path: Path at which to create the new alias.
116  """
117  if platform.system() == 'Windows' and not alias_path.endswith('.lnk'):
118    alias_path += '.lnk'
119  if os.path.lexists(alias_path):
120    os.remove(alias_path)
121  if platform.system() == 'Windows':
122    from win32com import client
123
124    shell = client.Dispatch('WScript.Shell')
125    shortcut = shell.CreateShortCut(alias_path)
126    shortcut.Targetpath = target_path
127    shortcut.save()
128  else:
129    os.symlink(target_path, alias_path)
130
131
132def get_current_epoch_time():
133  """Current epoch time in milliseconds.
134
135  Returns:
136    An integer representing the current epoch time in milliseconds.
137  """
138  return int(round(time.time() * 1000))
139
140
141def get_current_human_time():
142  """Returns the current time in human readable format.
143
144  Returns:
145    The current time stamp in Month-Day-Year Hour:Min:Sec format.
146  """
147  return time.strftime('%m-%d-%Y %H:%M:%S ')
148
149
150def epoch_to_human_time(epoch_time):
151  """Converts an epoch timestamp to human readable time.
152
153  This essentially converts an output of get_current_epoch_time to an output
154  of get_current_human_time
155
156  Args:
157    epoch_time: An integer representing an epoch timestamp in milliseconds.
158
159  Returns:
160    A time string representing the input time.
161    None if input param is invalid.
162  """
163  if isinstance(epoch_time, int):
164    try:
165      d = datetime.datetime.fromtimestamp(epoch_time / 1000)
166      return d.strftime('%m-%d-%Y %H:%M:%S ')
167    except ValueError:
168      return None
169
170
171def get_timezone_olson_id():
172  """Return the Olson ID of the local (non-DST) timezone.
173
174  Returns:
175    A string representing one of the Olson IDs of the local (non-DST)
176    timezone.
177  """
178  tzoffset = int(time.timezone / 3600)
179  if tzoffset <= 0:
180    gmt = f'GMT+{-tzoffset}'
181  else:
182    gmt = f'GMT-{tzoffset}'
183  return GMT_to_olson[gmt]
184
185
186def find_files(paths, file_predicate):
187  """Locate files whose names and extensions match the given predicate in
188  the specified directories.
189
190  Args:
191    paths: A list of directory paths where to find the files.
192    file_predicate: A function that returns True if the file name and
193      extension are desired.
194
195  Returns:
196    A list of files that match the predicate.
197  """
198  file_list = []
199  for path in paths:
200    p = abs_path(path)
201    for dirPath, _, fileList in os.walk(p):
202      for fname in fileList:
203        name, ext = os.path.splitext(fname)
204        if file_predicate(name, ext):
205          file_list.append((dirPath, name, ext))
206  return file_list
207
208
209def load_file_to_base64_str(f_path):
210  """Loads the content of a file into a base64 string.
211
212  Args:
213    f_path: full path to the file including the file name.
214
215  Returns:
216    A base64 string representing the content of the file in utf-8 encoding.
217  """
218  path = abs_path(f_path)
219  with io.open(path, 'rb') as f:
220    f_bytes = f.read()
221    base64_str = base64.b64encode(f_bytes).decode('utf-8')
222    return base64_str
223
224
225def find_field(item_list, cond, comparator, target_field):
226  """Finds the value of a field in a dict object that satisfies certain
227  conditions.
228
229  Args:
230    item_list: A list of dict objects.
231    cond: A param that defines the condition.
232    comparator: A function that checks if an dict satisfies the condition.
233    target_field: Name of the field whose value to be returned if an item
234      satisfies the condition.
235
236  Returns:
237    Target value or None if no item satisfies the condition.
238  """
239  for item in item_list:
240    if comparator(item, cond) and target_field in item:
241      return item[target_field]
242  return None
243
244
245def rand_ascii_str(length):
246  """Generates a random string of specified length, composed of ascii letters
247  and digits.
248
249  Args:
250    length: The number of characters in the string.
251
252  Returns:
253    The random string generated.
254  """
255  letters = [random.choice(ascii_letters_and_digits) for _ in range(length)]
256  return ''.join(letters)
257
258
259# Thead/Process related functions.
260def _collect_process_tree(starting_pid):
261  """Collects PID list of the descendant processes from the given PID.
262
263  This function only available on Unix like system.
264
265  Args:
266    starting_pid: The PID to start recursively traverse.
267
268  Returns:
269    A list of pid of the descendant processes.
270  """
271  ret = []
272  stack = [starting_pid]
273
274  while stack:
275    pid = stack.pop()
276    try:
277      ps_results = (
278          subprocess.check_output([
279              'ps',
280              '-o',
281              'pid',
282              '--ppid',
283              str(pid),
284              '--noheaders',
285          ])
286          .decode()
287          .strip()
288      )
289    except subprocess.CalledProcessError:
290      # Ignore if there is not child process.
291      continue
292
293    children_pid_list = [int(p.strip()) for p in ps_results.split('\n')]
294    stack.extend(children_pid_list)
295    ret.extend(children_pid_list)
296
297  return ret
298
299
300def _kill_process_tree(proc):
301  """Kills the subprocess and its descendants."""
302  if os.name == 'nt':
303    # The taskkill command with "/T" option ends the specified process and any
304    # child processes started by it:
305    # https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill
306    subprocess.check_output([
307        'taskkill',
308        '/F',
309        '/T',
310        '/PID',
311        str(proc.pid),
312    ])
313    return
314
315  failed = []
316  for child_pid in _collect_process_tree(proc.pid):
317    try:
318      os.kill(child_pid, signal.SIGTERM)
319    except Exception:  # pylint: disable=broad-except
320      failed.append(child_pid)
321      logging.exception('Failed to kill standing subprocess %d', child_pid)
322
323  try:
324    proc.kill()
325  except Exception:  # pylint: disable=broad-except
326    failed.append(proc.pid)
327    logging.exception('Failed to kill standing subprocess %d', proc.pid)
328
329  if failed:
330    raise Error('Failed to kill standing subprocesses: %s' % failed)
331
332
333def concurrent_exec(func, param_list, max_workers=30, raise_on_exception=False):
334  """Executes a function with different parameters pseudo-concurrently.
335
336  This is basically a map function. Each element (should be an iterable) in
337  the param_list is unpacked and passed into the function. Due to Python's
338  GIL, there's no true concurrency. This is suited for IO-bound tasks.
339
340  Args:
341    func: The function that performs a task.
342    param_list: A list of iterables, each being a set of params to be
343      passed into the function.
344    max_workers: int, the number of workers to use for parallelizing the
345      tasks. By default, this is 30 workers.
346    raise_on_exception: bool, raises all of the task failures if any of the
347      tasks failed if `True`. By default, this is `False`.
348
349  Returns:
350    A list of return values from each function execution. If an execution
351    caused an exception, the exception object will be the corresponding
352    result.
353
354  Raises:
355    RuntimeError: If executing any of the tasks failed and
356      `raise_on_exception` is True.
357  """
358  with concurrent.futures.ThreadPoolExecutor(
359      max_workers=max_workers
360  ) as executor:
361    # Start the load operations and mark each future with its params
362    future_to_params = {executor.submit(func, *p): p for p in param_list}
363    return_vals = []
364    exceptions = []
365    for future in concurrent.futures.as_completed(future_to_params):
366      params = future_to_params[future]
367      try:
368        return_vals.append(future.result())
369      except Exception as exc:  # pylint: disable=broad-except
370        logging.exception(
371            '%s generated an exception: %s', params, traceback.format_exc()
372        )
373        return_vals.append(exc)
374        exceptions.append(exc)
375    if raise_on_exception and exceptions:
376      error_messages = []
377      for exception in exceptions:
378        error_messages.append(
379            ''.join(
380                traceback.format_exception(
381                    exception.__class__, exception, exception.__traceback__
382                )
383            )
384        )
385      raise RuntimeError('\n\n'.join(error_messages))
386    return return_vals
387
388
389# Provide hint for pytype checker to avoid the Union[bytes, str] case.
390@overload
391def run_command(
392    cmd,
393    stdout=...,
394    stderr=...,
395    shell=...,
396    timeout=...,
397    cwd=...,
398    env=...,
399    universal_newlines: Literal[False] = ...,
400) -> Tuple[int, bytes, bytes]:
401  ...
402
403
404@overload
405def run_command(
406    cmd,
407    stdout=...,
408    stderr=...,
409    shell=...,
410    timeout=...,
411    cwd=...,
412    env=...,
413    universal_newlines: Literal[True] = ...,
414) -> Tuple[int, str, str]:
415  ...
416
417
418def run_command(
419    cmd,
420    stdout=None,
421    stderr=None,
422    shell=False,
423    timeout=None,
424    cwd=None,
425    env=None,
426    universal_newlines=False,
427):
428  """Runs a command in a subprocess.
429
430  This function is very similar to subprocess.check_output. The main
431  difference is that it returns the return code and std error output as well
432  as supporting a timeout parameter.
433
434  Args:
435    cmd: string or list of strings, the command to run.
436      See subprocess.Popen() documentation.
437    stdout: file handle, the file handle to write std out to. If None is
438      given, then subprocess.PIPE is used. See subprocess.Popen()
439      documentation.
440    stderr: file handle, the file handle to write std err to. If None is
441      given, then subprocess.PIPE is used. See subprocess.Popen()
442      documentation.
443    shell: bool, True to run this command through the system shell,
444      False to invoke it directly. See subprocess.Popen() docs.
445    timeout: float, the number of seconds to wait before timing out.
446      If not specified, no timeout takes effect.
447    cwd: string, the path to change the child's current directory to before
448      it is executed. Note that this directory is not considered when
449      searching the executable, so you can't specify the program's path
450      relative to cwd.
451    env: dict, a mapping that defines the environment variables for the
452      new process. Default behavior is inheriting the current process'
453      environment.
454    universal_newlines: bool, True to open file objects in text mode, False in
455      binary mode.
456
457  Returns:
458    A 3-tuple of the consisting of the return code, the std output, and the
459      std error.
460
461  Raises:
462    subprocess.TimeoutExpired: The command timed out.
463  """
464  if stdout is None:
465    stdout = subprocess.PIPE
466  if stderr is None:
467    stderr = subprocess.PIPE
468  process = subprocess.Popen(
469      cmd,
470      stdout=stdout,
471      stderr=stderr,
472      shell=shell,
473      cwd=cwd,
474      env=env,
475      universal_newlines=universal_newlines,
476  )
477  timer = None
478  timer_triggered = threading.Event()
479  if timeout and timeout > 0:
480    # The wait method on process will hang when used with PIPEs with large
481    # outputs, so use a timer thread instead.
482
483    def timeout_expired():
484      timer_triggered.set()
485      process.terminate()
486
487    timer = threading.Timer(timeout, timeout_expired)
488    timer.start()
489  # If the command takes longer than the timeout, then the timer thread
490  # will kill the subprocess, which will make it terminate.
491  out, err = process.communicate()
492  if timer is not None:
493    timer.cancel()
494  if timer_triggered.is_set():
495    raise subprocess.TimeoutExpired(
496        cmd=cmd, timeout=timeout, output=out, stderr=err
497    )
498  return process.returncode, out, err
499
500
501def start_standing_subprocess(cmd, shell=False, env=None):
502  """Starts a long-running subprocess.
503
504  This is not a blocking call and the subprocess started by it should be
505  explicitly terminated with stop_standing_subprocess.
506
507  For short-running commands, you should use subprocess.check_call, which
508  blocks.
509
510  Args:
511    cmd: string, the command to start the subprocess with.
512    shell: bool, True to run this command through the system shell,
513      False to invoke it directly. See subprocess.Proc() docs.
514    env: dict, a custom environment to run the standing subprocess. If not
515      specified, inherits the current environment. See subprocess.Popen()
516      docs.
517
518  Returns:
519    The subprocess that was started.
520  """
521  logging.debug('Starting standing subprocess with: %s', cmd)
522  proc = subprocess.Popen(
523      cmd,
524      stdin=subprocess.PIPE,
525      stdout=subprocess.PIPE,
526      stderr=subprocess.PIPE,
527      shell=shell,
528      env=env,
529  )
530  # Leaving stdin open causes problems for input, e.g. breaking the
531  # code.inspect() shell (http://stackoverflow.com/a/25512460/1612937), so
532  # explicitly close it assuming it is not needed for standing subprocesses.
533  proc.stdin.close()
534  proc.stdin = None
535  logging.debug('Started standing subprocess %d', proc.pid)
536  return proc
537
538
539def stop_standing_subprocess(proc):
540  """Stops a subprocess started by start_standing_subprocess.
541
542  Before killing the process, we check if the process is running, if it has
543  terminated, Error is raised.
544
545  Catches and ignores the PermissionError which only happens on Macs.
546
547  Args:
548    proc: Subprocess to terminate.
549
550  Raises:
551    Error: if the subprocess could not be stopped.
552  """
553  logging.debug('Stopping standing subprocess %d', proc.pid)
554
555  _kill_process_tree(proc)
556
557  # Call wait and close pipes on the original Python object so we don't get
558  # runtime warnings.
559  if proc.stdout:
560    proc.stdout.close()
561  if proc.stderr:
562    proc.stderr.close()
563  proc.wait()
564  logging.debug('Stopped standing subprocess %d', proc.pid)
565
566
567def wait_for_standing_subprocess(proc, timeout=None):
568  """Waits for a subprocess started by start_standing_subprocess to finish
569  or times out.
570
571  Propagates the exception raised by the subprocess.wait(.) function.
572  The subprocess.TimeoutExpired exception is raised if the process timed-out
573  rather than terminating.
574
575  If no exception is raised: the subprocess terminated on its own. No need
576  to call stop_standing_subprocess() to kill it.
577
578  If an exception is raised: the subprocess is still alive - it did not
579  terminate. Either call stop_standing_subprocess() to kill it, or call
580  wait_for_standing_subprocess() to keep waiting for it to terminate on its
581  own.
582
583  If the corresponding subprocess command generates a large amount of output
584  and this method is called with a timeout value, then the command can hang
585  indefinitely. See http://go/pylib/subprocess.html#subprocess.Popen.wait
586
587  This function does not support Python 2.
588
589  Args:
590    p: Subprocess to wait for.
591    timeout: An integer number of seconds to wait before timing out.
592  """
593  proc.wait(timeout)
594
595
596def get_available_host_port():
597  """Gets a host port number available for adb forward.
598
599  DEPRECATED: This method is unreliable. Pass `tcp:0` to adb forward instead.
600
601  Returns:
602    An integer representing a port number on the host available for adb
603    forward.
604
605  Raises:
606    Error: when no port is found after MAX_PORT_ALLOCATION_RETRY times.
607  """
608  logging.warning(
609      'The method mobly.utils.get_available_host_port is deprecated because it '
610      'is unreliable. Pass "tcp:0" to adb forward instead.'
611  )
612
613  # Only import adb module if needed.
614  from mobly.controllers.android_device_lib import adb
615
616  port = portpicker.pick_unused_port()
617  if not adb.is_adb_available():
618    return port
619  for _ in range(MAX_PORT_ALLOCATION_RETRY):
620    # Make sure adb is not using this port so we don't accidentally
621    # interrupt ongoing runs by trying to bind to the port.
622    if port not in adb.list_occupied_adb_ports():
623      return port
624    port = portpicker.pick_unused_port()
625  raise Error(
626      'Failed to find available port after {} retries'.format(
627          MAX_PORT_ALLOCATION_RETRY
628      )
629  )
630
631
632def grep(regex, output):
633  """Similar to linux's `grep`, this returns the line in an output stream
634  that matches a given regex pattern.
635
636  It does not rely on the `grep` binary and is not sensitive to line endings,
637  so it can be used cross-platform.
638
639  Args:
640    regex: string, a regex that matches the expected pattern.
641    output: byte string, the raw output of the adb cmd.
642
643  Returns:
644    A list of strings, all of which are output lines that matches the
645    regex pattern.
646  """
647  lines = output.decode('utf-8').strip().splitlines()
648  results = []
649  for line in lines:
650    if re.search(regex, line):
651      results.append(line.strip())
652  return results
653
654
655def cli_cmd_to_string(args):
656  """Converts a cmd arg list to string.
657
658  Args:
659    args: list of strings, the arguments of a command.
660
661  Returns:
662    String representation of the command.
663  """
664  if isinstance(args, str):
665    # Return directly if it's already a string.
666    return args
667  return ' '.join([shlex.quote(arg) for arg in args])
668
669
670def get_settable_properties(cls):
671  """Gets the settable properties of a class.
672
673  Only returns the explicitly defined properties with setters.
674
675  Args:
676    cls: A class in Python.
677  """
678  results = []
679  for attr, value in vars(cls).items():
680    if isinstance(value, property) and value.fset is not None:
681      results.append(attr)
682  return results
683
684
685def find_subclasses_in_module(base_classes, module):
686  """Finds the subclasses of the given classes in the given module.
687
688  Args:
689    base_classes: list of classes, the base classes to look for the
690      subclasses of in the module.
691    module: module, the module to look for the subclasses in.
692
693  Returns:
694    A list of all of the subclasses found in the module.
695  """
696  subclasses = []
697  for _, module_member in module.__dict__.items():
698    if inspect.isclass(module_member):
699      for base_class in base_classes:
700        if issubclass(module_member, base_class):
701          subclasses.append(module_member)
702  return subclasses
703
704
705def find_subclass_in_module(base_class, module):
706  """Finds the single subclass of the given base class in the given module.
707
708  Args:
709    base_class: class, the base class to look for a subclass of in the module.
710    module: module, the module to look for the single subclass in.
711
712  Returns:
713    The single subclass of the given base class.
714
715  Raises:
716    ValueError: If the number of subclasses found was not exactly one.
717  """
718  subclasses = find_subclasses_in_module([base_class], module)
719  if len(subclasses) != 1:
720    raise ValueError(
721        'Expected 1 subclass of %s per module, found %s.'
722        % (base_class.__name__, [subclass.__name__ for subclass in subclasses])
723    )
724  return subclasses[0]
725