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