1# Copyright 2017, The Android Open Source Project 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 15""" 16Atest Tradefed test runner class. 17""" 18 19from __future__ import print_function 20import json 21import logging 22import os 23import re 24import select 25import socket 26import subprocess 27 28from functools import partial 29 30# pylint: disable=import-error 31import atest_utils 32import constants 33import result_reporter 34from event_handler import EventHandler 35from test_finders import test_info 36from test_runners import test_runner_base 37 38POLL_FREQ_SECS = 10 39SOCKET_HOST = '127.0.0.1' 40SOCKET_QUEUE_MAX = 1 41SOCKET_BUFFER = 4096 42SELECT_TIMEOUT = 5 43 44# Socket Events of form FIRST_EVENT {JSON_DATA}\nSECOND_EVENT {JSON_DATA} 45# EVENT_RE has groups for the name and the data. "." does not match \n. 46EVENT_RE = re.compile(r'\n*(?P<event_name>[A-Z_]+) (?P<json_data>{.*})(?=\n|.)*') 47 48EXEC_DEPENDENCIES = ('adb', 'aapt') 49 50TRADEFED_EXIT_MSG = 'TradeFed subprocess exited early with exit code=%s.' 51 52LOG_FOLDER_NAME = 'log' 53 54_INTEGRATION_FINDERS = frozenset(['', 'INTEGRATION', 'INTEGRATION_FILE_PATH']) 55 56class TradeFedExitError(Exception): 57 """Raised when TradeFed exists before test run has finished.""" 58 59 60class AtestTradefedTestRunner(test_runner_base.TestRunnerBase): 61 """TradeFed Test Runner class.""" 62 NAME = 'AtestTradefedTestRunner' 63 EXECUTABLE = 'atest_tradefed.sh' 64 _TF_TEMPLATE = 'template/atest_local_min' 65 # Use --no-enable-granular-attempts to control reporter replay behavior. 66 # TODO(b/142630648): Enable option enable-granular-attempts in sharding mode. 67 _LOG_ARGS = ('--logcat-on-failure --atest-log-file-path={log_path} ' 68 '--no-enable-granular-attempts') 69 _RUN_CMD = ('{exe} {template} --template:map ' 70 'test=atest {tf_customize_template} {log_args} {args}') 71 _BUILD_REQ = {'tradefed-core'} 72 _RERUN_OPTION_GROUP = [constants.ITERATIONS, 73 constants.RERUN_UNTIL_FAILURE, 74 constants.RETRY_ANY_FAILURE] 75 76 def __init__(self, results_dir, module_info=None, **kwargs): 77 """Init stuff for base class.""" 78 super(AtestTradefedTestRunner, self).__init__(results_dir, **kwargs) 79 self.module_info = module_info 80 self.log_path = os.path.join(results_dir, LOG_FOLDER_NAME) 81 if not os.path.exists(self.log_path): 82 os.makedirs(self.log_path) 83 log_args = {'log_path': self.log_path} 84 self.run_cmd_dict = {'exe': self.EXECUTABLE, 85 'template': self._TF_TEMPLATE, 86 'tf_customize_template': '', 87 'args': '', 88 'log_args': self._LOG_ARGS.format(**log_args)} 89 self.is_verbose = logging.getLogger().isEnabledFor(logging.DEBUG) 90 self.root_dir = os.environ.get(constants.ANDROID_BUILD_TOP) 91 92 def _try_set_gts_authentication_key(self): 93 """Set GTS authentication key if it is available or exists. 94 95 Strategy: 96 Get APE_API_KEY from os.environ: 97 - If APE_API_KEY is already set by user -> do nothing. 98 Get the APE_API_KEY from constants: 99 - If the key file exists -> set to env var. 100 If APE_API_KEY isn't set and the key file doesn't exist: 101 - Warn user some GTS tests may fail without authentication. 102 """ 103 if os.environ.get('APE_API_KEY'): 104 logging.debug('APE_API_KEY is set by developer.') 105 return 106 ape_api_key = constants.GTS_GOOGLE_SERVICE_ACCOUNT 107 key_path = os.path.join(self.root_dir, ape_api_key) 108 if ape_api_key and os.path.exists(key_path): 109 logging.debug('Set APE_API_KEY: %s', ape_api_key) 110 os.environ['APE_API_KEY'] = key_path 111 else: 112 logging.debug('APE_API_KEY not set, some GTS tests may fail' 113 ' without authentication.') 114 115 def run_tests(self, test_infos, extra_args, reporter): 116 """Run the list of test_infos. See base class for more. 117 118 Args: 119 test_infos: A list of TestInfos. 120 extra_args: Dict of extra args to add to test run. 121 reporter: An instance of result_report.ResultReporter. 122 123 Returns: 124 0 if tests succeed, non-zero otherwise. 125 """ 126 reporter.log_path = self.log_path 127 reporter.rerun_options = self._extract_rerun_options(extra_args) 128 # Set google service key if it's available or found before running tests. 129 self._try_set_gts_authentication_key() 130 if os.getenv(test_runner_base.OLD_OUTPUT_ENV_VAR): 131 return self.run_tests_raw(test_infos, extra_args, reporter) 132 return self.run_tests_pretty(test_infos, extra_args, reporter) 133 134 def run_tests_raw(self, test_infos, extra_args, reporter): 135 """Run the list of test_infos. See base class for more. 136 137 Args: 138 test_infos: A list of TestInfos. 139 extra_args: Dict of extra args to add to test run. 140 reporter: An instance of result_report.ResultReporter. 141 142 Returns: 143 0 if tests succeed, non-zero otherwise. 144 """ 145 iterations = self._generate_iterations(extra_args) 146 reporter.register_unsupported_runner(self.NAME) 147 148 ret_code = constants.EXIT_CODE_SUCCESS 149 for _ in range(iterations): 150 run_cmds = self.generate_run_commands(test_infos, extra_args) 151 subproc = self.run(run_cmds[0], output_to_stdout=True, 152 env_vars=self.generate_env_vars(extra_args)) 153 ret_code |= self.wait_for_subprocess(subproc) 154 return ret_code 155 156 def run_tests_pretty(self, test_infos, extra_args, reporter): 157 """Run the list of test_infos. See base class for more. 158 159 Args: 160 test_infos: A list of TestInfos. 161 extra_args: Dict of extra args to add to test run. 162 reporter: An instance of result_report.ResultReporter. 163 164 Returns: 165 0 if tests succeed, non-zero otherwise. 166 """ 167 iterations = self._generate_iterations(extra_args) 168 ret_code = constants.EXIT_CODE_SUCCESS 169 for _ in range(iterations): 170 server = self._start_socket_server() 171 run_cmds = self.generate_run_commands(test_infos, extra_args, 172 server.getsockname()[1]) 173 subproc = self.run(run_cmds[0], output_to_stdout=self.is_verbose, 174 env_vars=self.generate_env_vars(extra_args)) 175 self.handle_subprocess(subproc, partial(self._start_monitor, 176 server, 177 subproc, 178 reporter)) 179 server.close() 180 ret_code |= self.wait_for_subprocess(subproc) 181 return ret_code 182 183 # pylint: disable=too-many-branches 184 def _start_monitor(self, server, tf_subproc, reporter): 185 """Polling and process event. 186 187 Args: 188 server: Socket server object. 189 tf_subproc: The tradefed subprocess to poll. 190 reporter: Result_Reporter object. 191 """ 192 inputs = [server] 193 event_handlers = {} 194 data_map = {} 195 inv_socket = None 196 while inputs: 197 try: 198 readable, _, _ = select.select(inputs, [], [], SELECT_TIMEOUT) 199 for socket_object in readable: 200 if socket_object is server: 201 conn, addr = socket_object.accept() 202 logging.debug('Accepted connection from %s', addr) 203 conn.setblocking(False) 204 inputs.append(conn) 205 data_map[conn] = '' 206 # The First connection should be invocation level reporter. 207 if not inv_socket: 208 inv_socket = conn 209 else: 210 # Count invocation level reporter events 211 # without showing real-time information. 212 if inv_socket == socket_object: 213 reporter.silent = True 214 event_handler = event_handlers.setdefault( 215 socket_object, EventHandler(reporter, self.NAME)) 216 else: 217 event_handler = event_handlers.setdefault( 218 socket_object, EventHandler( 219 result_reporter.ResultReporter(), self.NAME)) 220 recv_data = self._process_connection(data_map, 221 socket_object, 222 event_handler) 223 if not recv_data: 224 inputs.remove(socket_object) 225 socket_object.close() 226 finally: 227 # Subprocess ended and all socket client closed. 228 if tf_subproc.poll() is not None and len(inputs) == 1: 229 inputs.pop().close() 230 if not data_map: 231 raise TradeFedExitError(TRADEFED_EXIT_MSG 232 % tf_subproc.returncode) 233 234 def _process_connection(self, data_map, conn, event_handler): 235 """Process a socket connection between TF and ATest. 236 237 Expect data of form EVENT_NAME {JSON_DATA}. Multiple events will be 238 \n deliminated. Need to buffer data in case data exceeds socket 239 buffer. 240 E.q. 241 TEST_RUN_STARTED {runName":"hello_world_test","runAttempt":0}\n 242 TEST_STARTED {"start_time":2172917, "testName":"PrintHelloWorld"}\n 243 Args: 244 data_map: The data map of all connections. 245 conn: Socket connection. 246 event_handler: EventHandler object. 247 248 Returns: 249 True if conn.recv() has data , False otherwise. 250 """ 251 # Set connection into blocking mode. 252 conn.settimeout(None) 253 data = conn.recv(SOCKET_BUFFER) 254 logging.debug('received: %s', data) 255 if data: 256 data_map[conn] += data 257 while True: 258 match = EVENT_RE.match(data_map[conn]) 259 if not match: 260 break 261 try: 262 event_data = json.loads(match.group('json_data')) 263 except ValueError: 264 logging.debug('Json incomplete, wait for more data') 265 break 266 event_name = match.group('event_name') 267 event_handler.process_event(event_name, event_data) 268 data_map[conn] = data_map[conn][match.end():] 269 return bool(data) 270 271 def _start_socket_server(self): 272 """Start a TCP server.""" 273 server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 274 server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 275 # Port 0 lets the OS pick an open port between 1024 and 65535. 276 server.bind((SOCKET_HOST, 0)) 277 server.listen(SOCKET_QUEUE_MAX) 278 server.settimeout(POLL_FREQ_SECS) 279 logging.debug('Socket server started on port %s', 280 server.getsockname()[1]) 281 return server 282 283 def generate_env_vars(self, extra_args): 284 """Convert extra args into env vars.""" 285 env_vars = os.environ.copy() 286 debug_port = extra_args.get(constants.TF_DEBUG, '') 287 if debug_port: 288 env_vars['TF_DEBUG'] = 'true' 289 env_vars['TF_DEBUG_PORT'] = str(debug_port) 290 return env_vars 291 292 def host_env_check(self): 293 """Check that host env has everything we need. 294 295 We actually can assume the host env is fine because we have the same 296 requirements that atest has. Update this to check for android env vars 297 if that changes. 298 """ 299 pass 300 301 @staticmethod 302 def _is_missing_exec(executable): 303 """Check if system build executable is available. 304 305 Args: 306 executable: Executable we are checking for. 307 Returns: 308 True if executable is missing, False otherwise. 309 """ 310 try: 311 output = subprocess.check_output(['which', executable]) 312 except subprocess.CalledProcessError: 313 return True 314 # TODO: Check if there is a clever way to determine if system adb is 315 # good enough. 316 root_dir = os.environ.get(constants.ANDROID_BUILD_TOP) 317 return os.path.commonprefix([output, root_dir]) != root_dir 318 319 def get_test_runner_build_reqs(self): 320 """Return the build requirements. 321 322 Returns: 323 Set of build targets. 324 """ 325 build_req = self._BUILD_REQ 326 # Use different base build requirements if google-tf is around. 327 if self.module_info.is_module(constants.GTF_MODULE): 328 build_req = {constants.GTF_TARGET} 329 # Always add ATest's own TF target. 330 build_req.add(constants.ATEST_TF_MODULE) 331 # Add adb if we can't find it. 332 for executable in EXEC_DEPENDENCIES: 333 if self._is_missing_exec(executable): 334 build_req.add(executable) 335 return build_req 336 337 # pylint: disable=too-many-branches 338 # pylint: disable=too-many-statements 339 @staticmethod 340 def _parse_extra_args(extra_args): 341 """Convert the extra args into something tf can understand. 342 343 Args: 344 extra_args: Dict of args 345 346 Returns: 347 Tuple of args to append and args not supported. 348 """ 349 args_to_append = [] 350 args_not_supported = [] 351 for arg in extra_args: 352 if constants.WAIT_FOR_DEBUGGER == arg: 353 args_to_append.append('--wait-for-debugger') 354 continue 355 if constants.DISABLE_INSTALL == arg: 356 args_to_append.append('--disable-target-preparers') 357 continue 358 if constants.SERIAL == arg: 359 args_to_append.append('--serial') 360 args_to_append.append(extra_args[arg]) 361 continue 362 if constants.SHARDING == arg: 363 args_to_append.append('--shard-count') 364 args_to_append.append(str(extra_args[arg])) 365 continue 366 if constants.DISABLE_TEARDOWN == arg: 367 args_to_append.append('--disable-teardown') 368 continue 369 if constants.HOST == arg: 370 args_to_append.append('-n') 371 args_to_append.append('--prioritize-host-config') 372 args_to_append.append('--skip-host-arch-check') 373 continue 374 if constants.CUSTOM_ARGS == arg: 375 # We might need to sanitize it prior to appending but for now 376 # let's just treat it like a simple arg to pass on through. 377 args_to_append.extend(extra_args[arg]) 378 continue 379 if constants.ALL_ABI == arg: 380 args_to_append.append('--all-abi') 381 continue 382 if constants.DRY_RUN == arg: 383 continue 384 if constants.INSTANT == arg: 385 args_to_append.append('--enable-parameterized-modules') 386 args_to_append.append('--module-parameter') 387 args_to_append.append('instant_app') 388 continue 389 if constants.USER_TYPE == arg: 390 args_to_append.append('--enable-parameterized-modules') 391 args_to_append.append('--enable-optional-parameterization') 392 args_to_append.append('--module-parameter') 393 args_to_append.append(extra_args[arg]) 394 continue 395 if constants.ITERATIONS == arg: 396 args_to_append.append('--retry-strategy') 397 args_to_append.append(constants.ITERATIONS) 398 args_to_append.append('--max-testcase-run-count') 399 args_to_append.append(str(extra_args[arg])) 400 continue 401 if constants.RERUN_UNTIL_FAILURE == arg: 402 args_to_append.append('--retry-strategy') 403 args_to_append.append(constants.RERUN_UNTIL_FAILURE) 404 args_to_append.append('--max-testcase-run-count') 405 args_to_append.append(str(extra_args[arg])) 406 continue 407 if constants.RETRY_ANY_FAILURE == arg: 408 args_to_append.append('--retry-strategy') 409 args_to_append.append(constants.RETRY_ANY_FAILURE) 410 args_to_append.append('--max-testcase-run-count') 411 args_to_append.append(str(extra_args[arg])) 412 continue 413 if constants.COLLECT_TESTS_ONLY == arg: 414 args_to_append.append('--collect-tests-only') 415 continue 416 if constants.TF_DEBUG == arg: 417 print("Please attach process to your IDE...") 418 continue 419 args_not_supported.append(arg) 420 return args_to_append, args_not_supported 421 422 def _generate_metrics_folder(self, extra_args): 423 """Generate metrics folder.""" 424 metrics_folder = '' 425 if extra_args.get(constants.PRE_PATCH_ITERATIONS): 426 metrics_folder = os.path.join(self.results_dir, 'baseline-metrics') 427 elif extra_args.get(constants.POST_PATCH_ITERATIONS): 428 metrics_folder = os.path.join(self.results_dir, 'new-metrics') 429 return metrics_folder 430 431 def _generate_iterations(self, extra_args): 432 """Generate iterations.""" 433 iterations = 1 434 if extra_args.get(constants.PRE_PATCH_ITERATIONS): 435 iterations = extra_args.pop(constants.PRE_PATCH_ITERATIONS) 436 elif extra_args.get(constants.POST_PATCH_ITERATIONS): 437 iterations = extra_args.pop(constants.POST_PATCH_ITERATIONS) 438 return iterations 439 440 def generate_run_commands(self, test_infos, extra_args, port=None): 441 """Generate a single run command from TestInfos. 442 443 Args: 444 test_infos: A set of TestInfo instances. 445 extra_args: A Dict of extra args to append. 446 port: Optional. An int of the port number to send events to. If 447 None, then subprocess reporter in TF won't try to connect. 448 449 Returns: 450 A list that contains the string of atest tradefed run command. 451 Only one command is returned. 452 """ 453 args = self._create_test_args(test_infos) 454 metrics_folder = self._generate_metrics_folder(extra_args) 455 456 # Create a copy of args as more args could be added to the list. 457 test_args = list(args) 458 if port: 459 test_args.extend(['--subprocess-report-port', str(port)]) 460 if metrics_folder: 461 test_args.extend(['--metrics-folder', metrics_folder]) 462 logging.info('Saved metrics in: %s', metrics_folder) 463 log_level = 'WARN' 464 if self.is_verbose: 465 log_level = 'VERBOSE' 466 test_args.extend(['--log-level-display', log_level]) 467 test_args.extend(['--log-level', log_level]) 468 469 args_to_add, args_not_supported = self._parse_extra_args(extra_args) 470 471 # TODO(b/122889707) Remove this after finding the root cause. 472 env_serial = os.environ.get(constants.ANDROID_SERIAL) 473 # Use the env variable ANDROID_SERIAL if it's set by user but only when 474 # the target tests are not deviceless tests. 475 if env_serial and '--serial' not in args_to_add and '-n' not in args_to_add: 476 args_to_add.append("--serial") 477 args_to_add.append(env_serial) 478 479 test_args.extend(args_to_add) 480 if args_not_supported: 481 logging.info('%s does not support the following args %s', 482 self.EXECUTABLE, args_not_supported) 483 484 # Only need to check one TestInfo to determine if the tests are 485 # configured in TEST_MAPPING. 486 for_test_mapping = test_infos and test_infos[0].from_test_mapping 487 test_args.extend(atest_utils.get_result_server_args(for_test_mapping)) 488 self.run_cmd_dict['args'] = ' '.join(test_args) 489 self.run_cmd_dict['tf_customize_template'] = ( 490 self._extract_customize_tf_templates(extra_args)) 491 return [self._RUN_CMD.format(**self.run_cmd_dict)] 492 493 def _flatten_test_infos(self, test_infos): 494 """Sort and group test_infos by module_name and sort and group filters 495 by class name. 496 497 Example of three test_infos in a set: 498 Module1, {(classA, {})} 499 Module1, {(classB, {Method1})} 500 Module1, {(classB, {Method2}} 501 Becomes a set with one element: 502 Module1, {(ClassA, {}), (ClassB, {Method1, Method2})} 503 Where: 504 Each line is a test_info namedtuple 505 {} = Frozenset 506 () = TestFilter namedtuple 507 508 Args: 509 test_infos: A set of TestInfo namedtuples. 510 511 Returns: 512 A set of TestInfos flattened. 513 """ 514 results = set() 515 key = lambda x: x.test_name 516 for module, group in atest_utils.sort_and_group(test_infos, key): 517 # module is a string, group is a generator of grouped TestInfos. 518 # Module Test, so flatten test_infos: 519 no_filters = False 520 filters = set() 521 test_runner = None 522 test_finder = None 523 build_targets = set() 524 data = {} 525 module_args = [] 526 for test_info_i in group: 527 data.update(test_info_i.data) 528 # Extend data with constants.TI_MODULE_ARG instead of overwriting. 529 module_args.extend(test_info_i.data.get(constants.TI_MODULE_ARG, [])) 530 test_runner = test_info_i.test_runner 531 test_finder = test_info_i.test_finder 532 build_targets |= test_info_i.build_targets 533 test_filters = test_info_i.data.get(constants.TI_FILTER) 534 if not test_filters or no_filters: 535 # test_info wants whole module run, so hardcode no filters. 536 no_filters = True 537 filters = set() 538 continue 539 filters |= test_filters 540 if module_args: 541 data[constants.TI_MODULE_ARG] = module_args 542 data[constants.TI_FILTER] = self._flatten_test_filters(filters) 543 results.add( 544 test_info.TestInfo(test_name=module, 545 test_runner=test_runner, 546 test_finder=test_finder, 547 build_targets=build_targets, 548 data=data)) 549 return results 550 551 @staticmethod 552 def _flatten_test_filters(filters): 553 """Sort and group test_filters by class_name. 554 555 Example of three test_filters in a frozenset: 556 classA, {} 557 classB, {Method1} 558 classB, {Method2} 559 Becomes a frozenset with these elements: 560 classA, {} 561 classB, {Method1, Method2} 562 Where: 563 Each line is a TestFilter namedtuple 564 {} = Frozenset 565 566 Args: 567 filters: A frozenset of test_filters. 568 569 Returns: 570 A frozenset of test_filters flattened. 571 """ 572 results = set() 573 key = lambda x: x.class_name 574 for class_name, group in atest_utils.sort_and_group(filters, key): 575 # class_name is a string, group is a generator of TestFilters 576 assert class_name is not None 577 methods = set() 578 for test_filter in group: 579 if not test_filter.methods: 580 # Whole class should be run 581 methods = set() 582 break 583 methods |= test_filter.methods 584 results.add(test_info.TestFilter(class_name, frozenset(methods))) 585 return frozenset(results) 586 587 def _create_test_args(self, test_infos): 588 """Compile TF command line args based on the given test infos. 589 590 Args: 591 test_infos: A set of TestInfo instances. 592 593 Returns: A list of TF arguments to run the tests. 594 """ 595 args = [] 596 if not test_infos: 597 return [] 598 599 test_infos = self._flatten_test_infos(test_infos) 600 # In order to do dry-run verification, sort it to make each run has the 601 # same result 602 test_infos = list(test_infos) 603 test_infos.sort() 604 has_integration_test = False 605 for info in test_infos: 606 # Integration test exists in TF's jar, so it must have the option 607 # if it's integration finder. 608 if info.test_finder in _INTEGRATION_FINDERS: 609 has_integration_test = True 610 args.extend([constants.TF_INCLUDE_FILTER, info.test_name]) 611 filters = set() 612 for test_filter in info.data.get(constants.TI_FILTER, []): 613 filters.update(test_filter.to_set_of_tf_strings()) 614 for test_filter in filters: 615 filter_arg = constants.TF_ATEST_INCLUDE_FILTER_VALUE_FMT.format( 616 test_name=info.test_name, test_filter=test_filter) 617 args.extend([constants.TF_ATEST_INCLUDE_FILTER, filter_arg]) 618 for option in info.data.get(constants.TI_MODULE_ARG, []): 619 if constants.TF_INCLUDE_FILTER_OPTION == option[0]: 620 suite_filter = ( 621 constants.TF_SUITE_FILTER_ARG_VALUE_FMT.format( 622 test_name=info.test_name, option_value=option[1])) 623 args.extend([constants.TF_INCLUDE_FILTER, suite_filter]) 624 elif constants.TF_EXCLUDE_FILTER_OPTION == option[0]: 625 suite_filter = ( 626 constants.TF_SUITE_FILTER_ARG_VALUE_FMT.format( 627 test_name=info.test_name, option_value=option[1])) 628 args.extend([constants.TF_EXCLUDE_FILTER, suite_filter]) 629 else: 630 module_arg = ( 631 constants.TF_MODULE_ARG_VALUE_FMT.format( 632 test_name=info.test_name, option_name=option[0], 633 option_value=option[1])) 634 args.extend([constants.TF_MODULE_ARG, module_arg]) 635 # TODO (b/141090547) Pass the config path to TF to load configs. 636 # Compile option in TF if finder is not INTEGRATION or not set. 637 if not has_integration_test: 638 args.append(constants.TF_SKIP_LOADING_CONFIG_JAR) 639 return args 640 641 def _extract_rerun_options(self, extra_args): 642 """Extract rerun options to a string for output. 643 644 Args: 645 extra_args: Dict of extra args for test runners to use. 646 647 Returns: A string of rerun options. 648 """ 649 extracted_options = ['{} {}'.format(arg, extra_args[arg]) 650 for arg in extra_args 651 if arg in self._RERUN_OPTION_GROUP] 652 return ' '.join(extracted_options) 653 654 def _extract_customize_tf_templates(self, extra_args): 655 """Extract tradefed template options to a string for output. 656 657 Args: 658 extra_args: Dict of extra args for test runners to use. 659 660 Returns: A string of tradefed template options. 661 """ 662 return ''.join(['--template:map %s ' 663 % x for x in extra_args.get(constants.TF_TEMPLATE, [])]) 664