1# Copyright (c) 2018 Open Source Foundries Limited.
2# Copyright (c) 2023 Nordic Semiconductor ASA
3#
4# SPDX-License-Identifier: Apache-2.0
5
6'''Common code used by commands which execute runners.
7'''
8
9import re
10import argparse
11import logging
12from collections import defaultdict
13from os import close, getcwd, path, fspath
14from pathlib import Path
15from subprocess import CalledProcessError
16import sys
17import tempfile
18import textwrap
19import traceback
20
21from dataclasses import dataclass
22from west import log
23from build_helpers import find_build_dir, is_zephyr_build, load_domains, \
24    FIND_BUILD_DIR_DESCRIPTION
25from west.commands import CommandError
26from west.configuration import config
27from runners.core import FileType
28from runners.core import BuildConfiguration
29import yaml
30
31from zephyr_ext_common import ZEPHYR_SCRIPTS
32
33# Runners depend on edtlib. Make sure the copy in the tree is
34# available to them before trying to import any.
35sys.path.insert(0, str(ZEPHYR_SCRIPTS / 'dts' / 'python-devicetree' / 'src'))
36
37from runners import get_runner_cls, ZephyrBinaryRunner, MissingProgram
38from runners.core import RunnerConfig
39import zcmake
40
41# Context-sensitive help indentation.
42# Don't change this, or output from argparse won't match up.
43INDENT = ' ' * 2
44
45IGNORED_RUN_ONCE_PRIORITY = -1
46SOC_FILE_RUN_ONCE_DEFAULT_PRIORITY = 0
47BOARD_FILE_RUN_ONCE_DEFAULT_PRIORITY = 10
48
49if log.VERBOSE >= log.VERBOSE_NORMAL:
50    # Using level 1 allows sub-DEBUG levels of verbosity. The
51    # west.log module decides whether or not to actually print the
52    # message.
53    #
54    # https://docs.python.org/3.7/library/logging.html#logging-levels.
55    LOG_LEVEL = 1
56else:
57    LOG_LEVEL = logging.INFO
58
59def _banner(msg):
60    log.inf('-- ' + msg, colorize=True)
61
62class WestLogFormatter(logging.Formatter):
63
64    def __init__(self):
65        super().__init__(fmt='%(name)s: %(message)s')
66
67class WestLogHandler(logging.Handler):
68
69    def __init__(self, *args, **kwargs):
70        super().__init__(*args, **kwargs)
71        self.setFormatter(WestLogFormatter())
72        self.setLevel(LOG_LEVEL)
73
74    def emit(self, record):
75        fmt = self.format(record)
76        lvl = record.levelno
77        if lvl > logging.CRITICAL:
78            log.die(fmt)
79        elif lvl >= logging.ERROR:
80            log.err(fmt)
81        elif lvl >= logging.WARNING:
82            log.wrn(fmt)
83        elif lvl >= logging.INFO:
84            _banner(fmt)
85        elif lvl >= logging.DEBUG:
86            log.dbg(fmt)
87        else:
88            log.dbg(fmt, level=log.VERBOSE_EXTREME)
89
90@dataclass
91class UsedFlashCommand:
92    command: str
93    boards: list
94    runners: list
95    first: bool
96    ran: bool = False
97
98@dataclass
99class ImagesFlashed:
100    flashed: int = 0
101    total: int = 0
102
103@dataclass
104class SocBoardFilesProcessing:
105    filename: str
106    board: bool = False
107    priority: int = IGNORED_RUN_ONCE_PRIORITY
108    yaml: object = None
109
110def command_verb(command):
111    return "flash" if command.name == "flash" else "debug"
112
113def add_parser_common(command, parser_adder=None, parser=None):
114    if parser_adder is not None:
115        parser = parser_adder.add_parser(
116            command.name,
117            formatter_class=argparse.RawDescriptionHelpFormatter,
118            help=command.help,
119            description=command.description)
120
121    # Remember to update west-completion.bash if you add or remove
122    # flags
123
124    group = parser.add_argument_group('general options',
125                                      FIND_BUILD_DIR_DESCRIPTION)
126
127    group.add_argument('-d', '--build-dir', metavar='DIR',
128                       help='application build directory')
129    # still supported for backwards compatibility, but questionably
130    # useful now that we do everything with runners.yaml
131    group.add_argument('-c', '--cmake-cache', metavar='FILE',
132                       help=argparse.SUPPRESS)
133    group.add_argument('-r', '--runner',
134                       help='override default runner from --build-dir')
135    group.add_argument('--skip-rebuild', action='store_true',
136                       help='do not refresh cmake dependencies first')
137    group.add_argument('--domain', action='append',
138                       help='execute runner only for given domain')
139
140    group = parser.add_argument_group(
141        'runner configuration',
142        textwrap.dedent(f'''\
143        ===================================================================
144          IMPORTANT:
145          Individual runners support additional options not printed here.
146        ===================================================================
147
148        Run "west {command.name} --context" for runner-specific options.
149
150        If a build directory is found, --context also prints per-runner
151        settings found in that build directory's runners.yaml file.
152
153        Use "west {command.name} --context -r RUNNER" to limit output to a
154        specific RUNNER.
155
156        Some runner settings also can be overridden with options like
157        --hex-file. However, this depends on the runner: not all runners
158        respect --elf-file / --hex-file / --bin-file, nor use gdb or openocd,
159        etc.'''))
160    group.add_argument('-H', '--context', action='store_true',
161                       help='print runner- and build-specific help')
162    # Options used to override RunnerConfig values in runners.yaml.
163    # TODO: is this actually useful?
164    group.add_argument('--board-dir', metavar='DIR', help='board directory')
165    # FIXME: these are runner-specific and should be moved to where --context
166    # can find them instead.
167    group.add_argument('--gdb', help='path to GDB')
168    group.add_argument('--openocd', help='path to openocd')
169    group.add_argument(
170        '--openocd-search', metavar='DIR', action='append',
171        help='path to add to openocd search path, if applicable')
172
173    return parser
174
175def do_run_common(command, user_args, user_runner_args, domain_file=None):
176    # This is the main routine for all the "west flash", "west debug",
177    # etc. commands.
178
179    # Holds a list of run once commands, this is useful for sysbuild images
180    # whereby there are multiple images per board with flash commands that can
181    # interfere with other images if they run one per time an image is flashed.
182    used_cmds = []
183
184    # Holds a set of processed board names for flash running information.
185    processed_boards = set()
186
187    # Holds a dictionary of board image flash counts, the first element is
188    # number of images flashed so far and second element is total number of
189    # images for a given board.
190    board_image_count = defaultdict(ImagesFlashed)
191
192    highest_priority = IGNORED_RUN_ONCE_PRIORITY
193    highest_entry = None
194    check_files = []
195
196    if user_args.context:
197        dump_context(command, user_args, user_runner_args)
198        return
199
200    build_dir = get_build_dir(user_args)
201    if not user_args.skip_rebuild:
202        rebuild(command, build_dir, user_args)
203
204    if domain_file is None:
205        if user_args.domain is None:
206            # No domains are passed down and no domains specified by the user.
207            # So default domain will be used.
208            domains = [load_domains(build_dir).get_default_domain()]
209        else:
210            # No domains are passed down, but user has specified domains to use.
211            # Get the user specified domains.
212            domains = load_domains(build_dir).get_domains(user_args.domain)
213    else:
214        domains = load_domains(build_dir).get_domains(user_args.domain,
215                                                      default_flash_order=True)
216
217    if len(domains) > 1:
218        if len(user_runner_args) > 0:
219            log.wrn("Specifying runner options for multiple domains is experimental.\n"
220                    "If problems are experienced, please specify a single domain "
221                    "using '--domain <domain>'")
222
223        # Process all domains to load board names and populate flash runner
224        # parameters.
225        board_names = set()
226        for d in domains:
227            if d.build_dir is None:
228                build_dir = get_build_dir(user_args)
229            else:
230                build_dir = d.build_dir
231
232            cache = load_cmake_cache(build_dir, user_args)
233            build_conf = BuildConfiguration(build_dir)
234            board = build_conf.get('CONFIG_BOARD_TARGET')
235            board_names.add(board)
236            board_image_count[board].total += 1
237
238            # Load board flash runner configuration (if it exists) and store
239            # single-use commands in a dictionary so that they get executed
240            # once per unique board name.
241            for directory in cache.get_list('SOC_DIRECTORIES'):
242                if directory not in processed_boards:
243                    check_files.append(SocBoardFilesProcessing(Path(directory) / 'soc.yml'))
244                    processed_boards.add(directory)
245
246            for directory in cache.get_list('BOARD_DIRECTORIES'):
247                if directory not in processed_boards:
248                    check_files.append(SocBoardFilesProcessing(Path(directory) / 'board.yml', True))
249                    processed_boards.add(directory)
250
251        for check in check_files:
252            try:
253                with open(check.filename, 'r') as f:
254                    check.yaml = yaml.safe_load(f.read())
255
256                    if 'runners' not in check.yaml:
257                        continue
258                    elif check.board is False and 'run_once' not in check.yaml['runners']:
259                        continue
260
261                    if 'priority' in check.yaml['runners']:
262                        check.priority = check.yaml['runners']['priority']
263                    else:
264                        check.priority = BOARD_FILE_RUN_ONCE_DEFAULT_PRIORITY if check.board is True else SOC_FILE_RUN_ONCE_DEFAULT_PRIORITY
265
266                    if check.priority == highest_priority:
267                        log.die("Duplicate flash run once configuration found with equal priorities")
268
269                    elif check.priority > highest_priority:
270                        highest_priority = check.priority
271                        highest_entry = check
272
273            except FileNotFoundError:
274                continue
275
276        if highest_entry is not None:
277            group_type = 'boards' if highest_entry.board is True else 'qualifiers'
278
279            for cmd in highest_entry.yaml['runners']['run_once']:
280                for data in highest_entry.yaml['runners']['run_once'][cmd]:
281                    for group in data['groups']:
282                        run_first = bool(data['run'] == 'first')
283                        if group_type == 'qualifiers':
284                            targets = []
285                            for target in group[group_type]:
286                                # For SoC-based qualifiers, prepend to the beginning of the
287                                # match to allow for matching any board name
288                                targets.append('([^/]+)/' + target)
289                        else:
290                            targets = group[group_type]
291
292                        used_cmds.append(UsedFlashCommand(cmd, targets, data['runners'], run_first))
293
294    # Reduce entries to only those having matching board names (either exact or with regex) and
295    # remove any entries with empty board lists
296    for i, entry in enumerate(used_cmds):
297        for l, match in enumerate(entry.boards):
298            match_found = False
299
300            # Check if there is a matching board for this regex
301            for check in board_names:
302                if re.match(fr'^{match}$', check) is not None:
303                    match_found = True
304                    break
305
306            if not match_found:
307                del entry.boards[l]
308
309        if len(entry.boards) == 0:
310            del used_cmds[i]
311
312    prev_runner = None
313    for d in domains:
314        prev_runner = do_run_common_image(command, user_args, user_runner_args, used_cmds,
315                                          board_image_count, d.build_dir, prev_runner)
316
317
318def do_run_common_image(command, user_args, user_runner_args, used_cmds,
319                        board_image_count, build_dir=None, prev_runner=None):
320    global re
321    command_name = command.name
322    if build_dir is None:
323        build_dir = get_build_dir(user_args)
324    cache = load_cmake_cache(build_dir, user_args)
325    build_conf = BuildConfiguration(build_dir)
326    board = build_conf.get('CONFIG_BOARD_TARGET')
327
328    if board_image_count is not None and board in board_image_count:
329        board_image_count[board].flashed += 1
330
331    # Load runners.yaml.
332    yaml_path = runners_yaml_path(build_dir, board)
333    runners_yaml = load_runners_yaml(yaml_path)
334
335    # Get a concrete ZephyrBinaryRunner subclass to use based on
336    # runners.yaml and command line arguments.
337    runner_cls = use_runner_cls(command, board, user_args, runners_yaml,
338                                cache)
339    runner_name = runner_cls.name()
340
341    # Set up runner logging to delegate to west.log commands.
342    logger = logging.getLogger('runners')
343    logger.setLevel(LOG_LEVEL)
344    if not logger.hasHandlers():
345        # Only add a runners log handler if none has been added already.
346        logger.addHandler(WestLogHandler())
347
348    # If the user passed -- to force the parent argument parser to stop
349    # parsing, it will show up here, and needs to be filtered out.
350    runner_args = [arg for arg in user_runner_args if arg != '--']
351
352    # Check if there are any commands that should only be ran once per board
353    # and if so, remove them for all but the first iteration of the flash
354    # runner per unique board name.
355    if len(used_cmds) > 0 and len(runner_args) > 0:
356        i = len(runner_args) - 1
357        while i >= 0:
358            for cmd in used_cmds:
359                if cmd.command == runner_args[i] and (runner_name in cmd.runners or 'all' in cmd.runners):
360                    # Check if board is here
361                    match_found = False
362
363                    for match in cmd.boards:
364                        # Check if there is a matching board for this regex
365                        if re.match(fr'^{match}$', board) is not None:
366                            match_found = True
367                            break
368
369                    if not match_found:
370                        continue
371
372                    # Check if this is a first or last run
373                    if not cmd.first:
374                        # For last run instances, we need to check that this really is the last
375                        # image of all boards being flashed
376                        for check in cmd.boards:
377                            can_continue = False
378
379                            for match in board_image_count:
380                                if re.match(fr'^{check}$', match) is not None:
381                                    if board_image_count[match].flashed == board_image_count[match].total:
382                                        can_continue = True
383                                        break
384
385                        if not can_continue:
386                            continue
387
388                    if not cmd.ran:
389                        cmd.ran = True
390                    else:
391                        runner_args.pop(i)
392
393                    break
394
395            i = i - 1
396
397    # If flashing multiple images, the runner supports reset after flashing and
398    # the board has enabled this functionality, check if the board should be
399    # reset or not. If this is not specified in the board/soc file, leave it up to
400    # the runner's default configuration to decide if a reset should occur.
401    if runner_cls.capabilities().reset:
402        if board_image_count is not None:
403            reset = True
404
405            for cmd in used_cmds:
406                if cmd.command == '--reset' and (runner_name in cmd.runners or 'all' in cmd.runners):
407                    # Check if board is here
408                    match_found = False
409
410                    for match in cmd.boards:
411                        if re.match(fr'^{match}$', board) is not None:
412                            match_found = True
413                            break
414
415                    if not match_found:
416                        continue
417
418                    # Check if this is a first or last run
419                    if cmd.first and cmd.ran:
420                        reset = False
421                        break
422                    elif not cmd.first and not cmd.ran:
423                        # For last run instances, we need to check that this really is the last
424                        # image of all boards being flashed
425                        for check in cmd.boards:
426                            can_continue = False
427
428                            for match in board_image_count:
429                                if re.match(fr'^{check}$', match) is not None:
430                                    if board_image_count[match].flashed != board_image_count[match].total:
431                                        reset = False
432                                        break
433
434            if reset:
435                runner_args.append('--reset')
436            else:
437                runner_args.append('--no-reset')
438
439    # Arguments in this order to allow specific to override general:
440    #
441    # - runner-specific runners.yaml arguments
442    # - user-provided command line arguments
443    final_argv = runners_yaml['args'][runner_name] + runner_args
444
445    # 'user_args' contains parsed arguments which are:
446    #
447    # 1. provided on the command line, and
448    # 2. handled by add_parser_common(), and
449    # 3. *not* runner-specific
450    #
451    # 'final_argv' contains unparsed arguments from either:
452    #
453    # 1. runners.yaml, or
454    # 2. the command line
455    #
456    # We next have to:
457    #
458    # - parse 'final_argv' now that we have all the command line
459    #   arguments
460    # - create a RunnerConfig using 'user_args' and the result
461    #   of parsing 'final_argv'
462    parser = argparse.ArgumentParser(prog=runner_name, allow_abbrev=False)
463    add_parser_common(command, parser=parser)
464    runner_cls.add_parser(parser)
465    args, unknown = parser.parse_known_args(args=final_argv)
466    if unknown:
467        log.die(f'runner {runner_name} received unknown arguments: {unknown}')
468
469    # Propagate useful args from previous domain invocations
470    if prev_runner is not None:
471        runner_cls.args_from_previous_runner(prev_runner, args)
472
473    # Override args with any user_args. The latter must take
474    # precedence, or e.g. --hex-file on the command line would be
475    # ignored in favor of a board.cmake setting.
476    for a, v in vars(user_args).items():
477        if v is not None:
478            setattr(args, a, v)
479
480    # Create the RunnerConfig from runners.yaml and any command line
481    # overrides.
482    runner_config = get_runner_config(build_dir, yaml_path, runners_yaml, args)
483    log.dbg(f'runner_config: {runner_config}', level=log.VERBOSE_VERY)
484
485    # Use that RunnerConfig to create the ZephyrBinaryRunner instance
486    # and call its run().
487    try:
488        runner = runner_cls.create(runner_config, args)
489        runner.run(command_name)
490    except ValueError as ve:
491        log.err(str(ve), fatal=True)
492        dump_traceback()
493        raise CommandError(1)
494    except MissingProgram as e:
495        log.die('required program', e.filename,
496                'not found; install it or add its location to PATH')
497    except RuntimeError as re:
498        if not user_args.verbose:
499            log.die(re)
500        else:
501            log.err('verbose mode enabled, dumping stack:', fatal=True)
502            raise
503    return runner
504
505def get_build_dir(args, die_if_none=True):
506    # Get the build directory for the given argument list and environment.
507    if args.build_dir:
508        return args.build_dir
509
510    guess = config.get('build', 'guess-dir', fallback='never')
511    guess = guess == 'runners'
512    dir = find_build_dir(None, guess)
513
514    if dir and is_zephyr_build(dir):
515        return dir
516    elif die_if_none:
517        msg = '--build-dir was not given, '
518        if dir:
519            msg = msg + 'and neither {} nor {} are zephyr build directories.'
520        else:
521            msg = msg + ('{} is not a build directory and the default build '
522                         'directory cannot be determined. Check your '
523                         'build.dir-fmt configuration option')
524        log.die(msg.format(getcwd(), dir))
525    else:
526        return None
527
528def load_cmake_cache(build_dir, args):
529    cache_file = path.join(build_dir, args.cmake_cache or zcmake.DEFAULT_CACHE)
530    try:
531        return zcmake.CMakeCache(cache_file)
532    except FileNotFoundError:
533        log.die(f'no CMake cache found (expected one at {cache_file})')
534
535def rebuild(command, build_dir, args):
536    _banner(f'west {command.name}: rebuilding')
537    try:
538        zcmake.run_build(build_dir)
539    except CalledProcessError:
540        if args.build_dir:
541            log.die(f're-build in {args.build_dir} failed')
542        else:
543            log.die(f're-build in {build_dir} failed (no --build-dir given)')
544
545def runners_yaml_path(build_dir, board):
546    ret = Path(build_dir) / 'zephyr' / 'runners.yaml'
547    if not ret.is_file():
548        log.die(f'no runners.yaml found in {build_dir}/zephyr. '
549        f"Either board {board} doesn't support west flash/debug/simulate,"
550        ' or a pristine build is needed.')
551    return ret
552
553def load_runners_yaml(path):
554    # Load runners.yaml and convert to Python object.
555
556    try:
557        with open(path, 'r') as f:
558            content = yaml.safe_load(f.read())
559    except FileNotFoundError:
560        log.die(f'runners.yaml file not found: {path}')
561
562    if not content.get('runners'):
563        log.wrn(f'no pre-configured runners in {path}; '
564                "this probably won't work")
565
566    return content
567
568def use_runner_cls(command, board, args, runners_yaml, cache):
569    # Get the ZephyrBinaryRunner class from its name, and make sure it
570    # supports the command. Print a message about the choice, and
571    # return the class.
572
573    runner = args.runner or runners_yaml.get(command.runner_key)
574    if runner is None:
575        log.die(f'no {command.name} runner available for board {board}. '
576                "Check the board's documentation for instructions.")
577
578    _banner(f'west {command.name}: using runner {runner}')
579
580    available = runners_yaml.get('runners', [])
581    if runner not in available:
582        if 'BOARD_DIR' in cache:
583            board_cmake = Path(cache['BOARD_DIR']) / 'board.cmake'
584        else:
585            board_cmake = 'board.cmake'
586        log.err(f'board {board} does not support runner {runner}',
587                fatal=True)
588        log.inf(f'To fix, configure this runner in {board_cmake} and rebuild.')
589        sys.exit(1)
590    try:
591        runner_cls = get_runner_cls(runner)
592    except ValueError as e:
593        log.die(e)
594    if command.name not in runner_cls.capabilities().commands:
595        log.die(f'runner {runner} does not support command {command.name}')
596
597    return runner_cls
598
599def get_runner_config(build_dir, yaml_path, runners_yaml, args=None):
600    # Get a RunnerConfig object for the current run. yaml_config is
601    # runners.yaml's config: map, and args are the command line arguments.
602    yaml_config = runners_yaml['config']
603    yaml_dir = yaml_path.parent
604    if args is None:
605        args = argparse.Namespace()
606
607    def output_file(filetype):
608
609        from_args = getattr(args, f'{filetype}_file', None)
610        if from_args is not None:
611            return from_args
612
613        from_yaml = yaml_config.get(f'{filetype}_file')
614        if from_yaml is not None:
615            # Output paths in runners.yaml are relative to the
616            # directory containing the runners.yaml file.
617            return fspath(yaml_dir / from_yaml)
618
619        return None
620
621    def config(attr, default=None):
622        return getattr(args, attr, None) or yaml_config.get(attr, default)
623
624    def filetype(attr):
625        ftype = str(getattr(args, attr, None)).lower()
626        if ftype == "hex":
627            return FileType.HEX
628        elif ftype == "bin":
629            return FileType.BIN
630        elif ftype == "elf":
631            return FileType.ELF
632        elif getattr(args, attr, None) is not None:
633            err = 'unknown --file-type ({}). Please use hex, bin or elf'
634            raise ValueError(err.format(ftype))
635
636        # file-type not provided, try to get from filename
637        file = getattr(args, "file", None)
638        if file is not None:
639            ext = Path(file).suffix
640            if ext == ".hex":
641                return FileType.HEX
642            if ext == ".bin":
643                return FileType.BIN
644            if ext == ".elf":
645                return FileType.ELF
646
647        # we couldn't get the file-type, set to
648        # OTHER and let the runner deal with it
649        return FileType.OTHER
650
651    return RunnerConfig(build_dir,
652                        yaml_config['board_dir'],
653                        output_file('elf'),
654                        output_file('exe'),
655                        output_file('hex'),
656                        output_file('bin'),
657                        output_file('uf2'),
658                        config('file'),
659                        filetype('file_type'),
660                        config('gdb'),
661                        config('openocd'),
662                        config('openocd_search', []),
663                        config('rtt_address'))
664
665def dump_traceback():
666    # Save the current exception to a file and return its path.
667    fd, name = tempfile.mkstemp(prefix='west-exc-', suffix='.txt')
668    close(fd)        # traceback has no use for the fd
669    with open(name, 'w') as f:
670        traceback.print_exc(file=f)
671    log.inf("An exception trace has been saved in", name)
672
673#
674# west {command} --context
675#
676
677def dump_context(command, args, unknown_args):
678    build_dir = get_build_dir(args, die_if_none=False)
679    if build_dir is None:
680        log.wrn('no --build-dir given or found; output will be limited')
681        runners_yaml = None
682    else:
683        build_conf = BuildConfiguration(build_dir)
684        board = build_conf.get('CONFIG_BOARD_TARGET')
685        yaml_path = runners_yaml_path(build_dir, board)
686        runners_yaml = load_runners_yaml(yaml_path)
687
688    # Re-build unless asked not to, to make sure the output is up to date.
689    if build_dir and not args.skip_rebuild:
690        rebuild(command, build_dir, args)
691
692    if args.runner:
693        try:
694            cls = get_runner_cls(args.runner)
695        except ValueError:
696            log.die(f'invalid runner name {args.runner}; choices: ' +
697                    ', '.join(cls.name() for cls in
698                              ZephyrBinaryRunner.get_runners()))
699    else:
700        cls = None
701
702    if runners_yaml is None:
703        dump_context_no_config(command, cls)
704    else:
705        log.inf(f'build configuration:', colorize=True)
706        log.inf(f'{INDENT}build directory: {build_dir}')
707        log.inf(f'{INDENT}board: {board}')
708        log.inf(f'{INDENT}runners.yaml: {yaml_path}')
709        if cls:
710            dump_runner_context(command, cls, runners_yaml)
711        else:
712            dump_all_runner_context(command, runners_yaml, board, build_dir)
713
714def dump_context_no_config(command, cls):
715    if not cls:
716        all_cls = {cls.name(): cls for cls in ZephyrBinaryRunner.get_runners()
717                   if command.name in cls.capabilities().commands}
718        log.inf('all Zephyr runners which support {}:'.format(command.name),
719                colorize=True)
720        dump_wrapped_lines(', '.join(all_cls.keys()), INDENT)
721        log.inf()
722        log.inf('Note: use -r RUNNER to limit information to one runner.')
723    else:
724        # This does the right thing with a None argument.
725        dump_runner_context(command, cls, None)
726
727def dump_runner_context(command, cls, runners_yaml, indent=''):
728    dump_runner_caps(cls, indent)
729    dump_runner_option_help(cls, indent)
730
731    if runners_yaml is None:
732        return
733
734    if cls.name() in runners_yaml['runners']:
735        dump_runner_args(cls.name(), runners_yaml, indent)
736    else:
737        log.wrn(f'support for runner {cls.name()} is not configured '
738                f'in this build directory')
739
740def dump_runner_caps(cls, indent=''):
741    # Print RunnerCaps for the given runner class.
742
743    log.inf(f'{indent}{cls.name()} capabilities:', colorize=True)
744    log.inf(f'{indent}{INDENT}{cls.capabilities()}')
745
746def dump_runner_option_help(cls, indent=''):
747    # Print help text for class-specific command line options for the
748    # given runner class.
749
750    dummy_parser = argparse.ArgumentParser(prog='', add_help=False, allow_abbrev=False)
751    cls.add_parser(dummy_parser)
752    formatter = dummy_parser._get_formatter()
753    for group in dummy_parser._action_groups:
754        # Break the abstraction to filter out the 'flash', 'debug', etc.
755        # TODO: come up with something cleaner (may require changes
756        # in the runner core).
757        actions = group._group_actions
758        if len(actions) == 1 and actions[0].dest == 'command':
759            # This is the lone positional argument. Skip it.
760            continue
761        formatter.start_section('REMOVE ME')
762        formatter.add_text(group.description)
763        formatter.add_arguments(actions)
764        formatter.end_section()
765    # Get the runner help, with the "REMOVE ME" string gone
766    runner_help = f'\n{indent}'.join(formatter.format_help().splitlines()[1:])
767
768    log.inf(f'{indent}{cls.name()} options:', colorize=True)
769    log.inf(indent + runner_help)
770
771def dump_runner_args(group, runners_yaml, indent=''):
772    msg = f'{indent}{group} arguments from runners.yaml:'
773    args = runners_yaml['args'][group]
774    if args:
775        log.inf(msg, colorize=True)
776        for arg in args:
777            log.inf(f'{indent}{INDENT}{arg}')
778    else:
779        log.inf(f'{msg} (none)', colorize=True)
780
781def dump_all_runner_context(command, runners_yaml, board, build_dir):
782    all_cls = {cls.name(): cls for cls in ZephyrBinaryRunner.get_runners() if
783               command.name in cls.capabilities().commands}
784    available = runners_yaml['runners']
785    available_cls = {r: all_cls[r] for r in available if r in all_cls}
786    default_runner = runners_yaml[command.runner_key]
787    yaml_path = runners_yaml_path(build_dir, board)
788    runners_yaml = load_runners_yaml(yaml_path)
789
790    log.inf(f'zephyr runners which support "west {command.name}":',
791            colorize=True)
792    dump_wrapped_lines(', '.join(all_cls.keys()), INDENT)
793    log.inf()
794    dump_wrapped_lines('Note: not all may work with this board and build '
795                       'directory. Available runners are listed below.',
796                       INDENT)
797
798    log.inf(f'available runners in runners.yaml:',
799            colorize=True)
800    dump_wrapped_lines(', '.join(available), INDENT)
801    log.inf(f'default runner in runners.yaml:', colorize=True)
802    log.inf(INDENT + default_runner)
803    log.inf('common runner configuration:', colorize=True)
804    runner_config = get_runner_config(build_dir, yaml_path, runners_yaml)
805    for field, value in zip(runner_config._fields, runner_config):
806        log.inf(f'{INDENT}- {field}: {value}')
807    log.inf('runner-specific context:', colorize=True)
808    for cls in available_cls.values():
809        dump_runner_context(command, cls, runners_yaml, INDENT)
810
811    if len(available) > 1:
812        log.inf()
813        log.inf('Note: use -r RUNNER to limit information to one runner.')
814
815def dump_wrapped_lines(text, indent):
816    for line in textwrap.wrap(text, initial_indent=indent,
817                              subsequent_indent=indent,
818                              break_on_hyphens=False,
819                              break_long_words=False):
820        log.inf(line)
821