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