• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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