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