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