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