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