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