zephyr/scripts/west_commands/run_common.py

821 lines
31 KiB
Python

# Copyright (c) 2018 Open Source Foundries Limited.
# Copyright (c) 2023 Nordic Semiconductor ASA
#
# SPDX-License-Identifier: Apache-2.0
'''Common code used by commands which execute runners.
'''
import re
import argparse
import logging
from collections import defaultdict
from os import close, getcwd, path, fspath
from pathlib import Path
from subprocess import CalledProcessError
import sys
import tempfile
import textwrap
import traceback
from dataclasses import dataclass
from west import log
from build_helpers import find_build_dir, is_zephyr_build, load_domains, \
FIND_BUILD_DIR_DESCRIPTION
from west.commands import CommandError
from west.configuration import config
from runners.core import FileType
from runners.core import BuildConfiguration
import yaml
from zephyr_ext_common import ZEPHYR_SCRIPTS
# Runners depend on edtlib. Make sure the copy in the tree is
# available to them before trying to import any.
sys.path.insert(0, str(ZEPHYR_SCRIPTS / 'dts' / 'python-devicetree' / 'src'))
from runners import get_runner_cls, ZephyrBinaryRunner, MissingProgram
from runners.core import RunnerConfig
import zcmake
# Context-sensitive help indentation.
# Don't change this, or output from argparse won't match up.
INDENT = ' ' * 2
IGNORED_RUN_ONCE_PRIORITY = -1
SOC_FILE_RUN_ONCE_DEFAULT_PRIORITY = 0
BOARD_FILE_RUN_ONCE_DEFAULT_PRIORITY = 10
if log.VERBOSE >= log.VERBOSE_NORMAL:
# Using level 1 allows sub-DEBUG levels of verbosity. The
# west.log module decides whether or not to actually print the
# message.
#
# https://docs.python.org/3.7/library/logging.html#logging-levels.
LOG_LEVEL = 1
else:
LOG_LEVEL = logging.INFO
def _banner(msg):
log.inf('-- ' + msg, colorize=True)
class WestLogFormatter(logging.Formatter):
def __init__(self):
super().__init__(fmt='%(name)s: %(message)s')
class WestLogHandler(logging.Handler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFormatter(WestLogFormatter())
self.setLevel(LOG_LEVEL)
def emit(self, record):
fmt = self.format(record)
lvl = record.levelno
if lvl > logging.CRITICAL:
log.die(fmt)
elif lvl >= logging.ERROR:
log.err(fmt)
elif lvl >= logging.WARNING:
log.wrn(fmt)
elif lvl >= logging.INFO:
_banner(fmt)
elif lvl >= logging.DEBUG:
log.dbg(fmt)
else:
log.dbg(fmt, level=log.VERBOSE_EXTREME)
@dataclass
class UsedFlashCommand:
command: str
boards: list
runners: list
first: bool
ran: bool = False
@dataclass
class ImagesFlashed:
flashed: int = 0
total: int = 0
@dataclass
class SocBoardFilesProcessing:
filename: str
board: bool = False
priority: int = IGNORED_RUN_ONCE_PRIORITY
yaml: object = None
def command_verb(command):
return "flash" if command.name == "flash" else "debug"
def add_parser_common(command, parser_adder=None, parser=None):
if parser_adder is not None:
parser = parser_adder.add_parser(
command.name,
formatter_class=argparse.RawDescriptionHelpFormatter,
help=command.help,
description=command.description)
# Remember to update west-completion.bash if you add or remove
# flags
group = parser.add_argument_group('general options',
FIND_BUILD_DIR_DESCRIPTION)
group.add_argument('-d', '--build-dir', metavar='DIR',
help='application build directory')
# still supported for backwards compatibility, but questionably
# useful now that we do everything with runners.yaml
group.add_argument('-c', '--cmake-cache', metavar='FILE',
help=argparse.SUPPRESS)
group.add_argument('-r', '--runner',
help='override default runner from --build-dir')
group.add_argument('--skip-rebuild', action='store_true',
help='do not refresh cmake dependencies first')
group.add_argument('--domain', action='append',
help='execute runner only for given domain')
group = parser.add_argument_group(
'runner configuration',
textwrap.dedent(f'''\
===================================================================
IMPORTANT:
Individual runners support additional options not printed here.
===================================================================
Run "west {command.name} --context" for runner-specific options.
If a build directory is found, --context also prints per-runner
settings found in that build directory's runners.yaml file.
Use "west {command.name} --context -r RUNNER" to limit output to a
specific RUNNER.
Some runner settings also can be overridden with options like
--hex-file. However, this depends on the runner: not all runners
respect --elf-file / --hex-file / --bin-file, nor use gdb or openocd,
etc.'''))
group.add_argument('-H', '--context', action='store_true',
help='print runner- and build-specific help')
# Options used to override RunnerConfig values in runners.yaml.
# TODO: is this actually useful?
group.add_argument('--board-dir', metavar='DIR', help='board directory')
# FIXME: these are runner-specific and should be moved to where --context
# can find them instead.
group.add_argument('--gdb', help='path to GDB')
group.add_argument('--openocd', help='path to openocd')
group.add_argument(
'--openocd-search', metavar='DIR', action='append',
help='path to add to openocd search path, if applicable')
return parser
def do_run_common(command, user_args, user_runner_args, domain_file=None):
# This is the main routine for all the "west flash", "west debug",
# etc. commands.
# Holds a list of run once commands, this is useful for sysbuild images
# whereby there are multiple images per board with flash commands that can
# interfere with other images if they run one per time an image is flashed.
used_cmds = []
# Holds a set of processed board names for flash running information.
processed_boards = set()
# Holds a dictionary of board image flash counts, the first element is
# number of images flashed so far and second element is total number of
# images for a given board.
board_image_count = defaultdict(ImagesFlashed)
highest_priority = IGNORED_RUN_ONCE_PRIORITY
highest_entry = None
check_files = []
if user_args.context:
dump_context(command, user_args, user_runner_args)
return
build_dir = get_build_dir(user_args)
if not user_args.skip_rebuild:
rebuild(command, build_dir, user_args)
if domain_file is None:
if user_args.domain is None:
# No domains are passed down and no domains specified by the user.
# So default domain will be used.
domains = [load_domains(build_dir).get_default_domain()]
else:
# No domains are passed down, but user has specified domains to use.
# Get the user specified domains.
domains = load_domains(build_dir).get_domains(user_args.domain)
else:
domains = load_domains(build_dir).get_domains(user_args.domain,
default_flash_order=True)
if len(domains) > 1:
if len(user_runner_args) > 0:
log.wrn("Specifying runner options for multiple domains is experimental.\n"
"If problems are experienced, please specify a single domain "
"using '--domain <domain>'")
# Process all domains to load board names and populate flash runner
# parameters.
board_names = set()
for d in domains:
if d.build_dir is None:
build_dir = get_build_dir(user_args)
else:
build_dir = d.build_dir
cache = load_cmake_cache(build_dir, user_args)
build_conf = BuildConfiguration(build_dir)
board = build_conf.get('CONFIG_BOARD_TARGET')
board_names.add(board)
board_image_count[board].total += 1
# Load board flash runner configuration (if it exists) and store
# single-use commands in a dictionary so that they get executed
# once per unique board name.
for directory in cache.get_list('SOC_DIRECTORIES'):
if directory not in processed_boards:
check_files.append(SocBoardFilesProcessing(Path(directory) / 'soc.yml'))
processed_boards.add(directory)
for directory in cache.get_list('BOARD_DIRECTORIES'):
if directory not in processed_boards:
check_files.append(SocBoardFilesProcessing(Path(directory) / 'board.yml', True))
processed_boards.add(directory)
for check in check_files:
try:
with open(check.filename, 'r') as f:
check.yaml = yaml.safe_load(f.read())
if 'runners' not in check.yaml:
continue
elif check.board is False and 'run_once' not in check.yaml['runners']:
continue
if 'priority' in check.yaml['runners']:
check.priority = check.yaml['runners']['priority']
else:
check.priority = BOARD_FILE_RUN_ONCE_DEFAULT_PRIORITY if check.board is True else SOC_FILE_RUN_ONCE_DEFAULT_PRIORITY
if check.priority == highest_priority:
log.die("Duplicate flash run once configuration found with equal priorities")
elif check.priority > highest_priority:
highest_priority = check.priority
highest_entry = check
except FileNotFoundError:
continue
if highest_entry is not None:
group_type = 'boards' if highest_entry.board is True else 'qualifiers'
for cmd in highest_entry.yaml['runners']['run_once']:
for data in highest_entry.yaml['runners']['run_once'][cmd]:
for group in data['groups']:
run_first = bool(data['run'] == 'first')
if group_type == 'qualifiers':
targets = []
for target in group[group_type]:
# For SoC-based qualifiers, prepend to the beginning of the
# match to allow for matching any board name
targets.append('([^/]+)/' + target)
else:
targets = group[group_type]
used_cmds.append(UsedFlashCommand(cmd, targets, data['runners'], run_first))
# Reduce entries to only those having matching board names (either exact or with regex) and
# remove any entries with empty board lists
for i, entry in enumerate(used_cmds):
for l, match in enumerate(entry.boards):
match_found = False
# Check if there is a matching board for this regex
for check in board_names:
if re.match(fr'^{match}$', check) is not None:
match_found = True
break
if not match_found:
del entry.boards[l]
if len(entry.boards) == 0:
del used_cmds[i]
prev_runner = None
for d in domains:
prev_runner = do_run_common_image(command, user_args, user_runner_args, used_cmds,
board_image_count, d.build_dir, prev_runner)
def do_run_common_image(command, user_args, user_runner_args, used_cmds,
board_image_count, build_dir=None, prev_runner=None):
global re
command_name = command.name
if build_dir is None:
build_dir = get_build_dir(user_args)
cache = load_cmake_cache(build_dir, user_args)
build_conf = BuildConfiguration(build_dir)
board = build_conf.get('CONFIG_BOARD_TARGET')
if board_image_count is not None and board in board_image_count:
board_image_count[board].flashed += 1
# Load runners.yaml.
yaml_path = runners_yaml_path(build_dir, board)
runners_yaml = load_runners_yaml(yaml_path)
# Get a concrete ZephyrBinaryRunner subclass to use based on
# runners.yaml and command line arguments.
runner_cls = use_runner_cls(command, board, user_args, runners_yaml,
cache)
runner_name = runner_cls.name()
# Set up runner logging to delegate to west.log commands.
logger = logging.getLogger('runners')
logger.setLevel(LOG_LEVEL)
if not logger.hasHandlers():
# Only add a runners log handler if none has been added already.
logger.addHandler(WestLogHandler())
# If the user passed -- to force the parent argument parser to stop
# parsing, it will show up here, and needs to be filtered out.
runner_args = [arg for arg in user_runner_args if arg != '--']
# Check if there are any commands that should only be ran once per board
# and if so, remove them for all but the first iteration of the flash
# runner per unique board name.
if len(used_cmds) > 0 and len(runner_args) > 0:
i = len(runner_args) - 1
while i >= 0:
for cmd in used_cmds:
if cmd.command == runner_args[i] and (runner_name in cmd.runners or 'all' in cmd.runners):
# Check if board is here
match_found = False
for match in cmd.boards:
# Check if there is a matching board for this regex
if re.match(fr'^{match}$', board) is not None:
match_found = True
break
if not match_found:
continue
# Check if this is a first or last run
if not cmd.first:
# For last run instances, we need to check that this really is the last
# image of all boards being flashed
for check in cmd.boards:
can_continue = False
for match in board_image_count:
if re.match(fr'^{check}$', match) is not None:
if board_image_count[match].flashed == board_image_count[match].total:
can_continue = True
break
if not can_continue:
continue
if not cmd.ran:
cmd.ran = True
else:
runner_args.pop(i)
break
i = i - 1
# If flashing multiple images, the runner supports reset after flashing and
# the board has enabled this functionality, check if the board should be
# reset or not. If this is not specified in the board/soc file, leave it up to
# the runner's default configuration to decide if a reset should occur.
if runner_cls.capabilities().reset:
if board_image_count is not None:
reset = True
for cmd in used_cmds:
if cmd.command == '--reset' and (runner_name in cmd.runners or 'all' in cmd.runners):
# Check if board is here
match_found = False
for match in cmd.boards:
if re.match(fr'^{match}$', board) is not None:
match_found = True
break
if not match_found:
continue
# Check if this is a first or last run
if cmd.first and cmd.ran:
reset = False
break
elif not cmd.first and not cmd.ran:
# For last run instances, we need to check that this really is the last
# image of all boards being flashed
for check in cmd.boards:
can_continue = False
for match in board_image_count:
if re.match(fr'^{check}$', match) is not None:
if board_image_count[match].flashed != board_image_count[match].total:
reset = False
break
if reset:
runner_args.append('--reset')
else:
runner_args.append('--no-reset')
# Arguments in this order to allow specific to override general:
#
# - runner-specific runners.yaml arguments
# - user-provided command line arguments
final_argv = runners_yaml['args'][runner_name] + runner_args
# 'user_args' contains parsed arguments which are:
#
# 1. provided on the command line, and
# 2. handled by add_parser_common(), and
# 3. *not* runner-specific
#
# 'final_argv' contains unparsed arguments from either:
#
# 1. runners.yaml, or
# 2. the command line
#
# We next have to:
#
# - parse 'final_argv' now that we have all the command line
# arguments
# - create a RunnerConfig using 'user_args' and the result
# of parsing 'final_argv'
parser = argparse.ArgumentParser(prog=runner_name, allow_abbrev=False)
add_parser_common(command, parser=parser)
runner_cls.add_parser(parser)
args, unknown = parser.parse_known_args(args=final_argv)
if unknown:
log.die(f'runner {runner_name} received unknown arguments: {unknown}')
# Propagate useful args from previous domain invocations
if prev_runner is not None:
runner_cls.args_from_previous_runner(prev_runner, args)
# Override args with any user_args. The latter must take
# precedence, or e.g. --hex-file on the command line would be
# ignored in favor of a board.cmake setting.
for a, v in vars(user_args).items():
if v is not None:
setattr(args, a, v)
# Create the RunnerConfig from runners.yaml and any command line
# overrides.
runner_config = get_runner_config(build_dir, yaml_path, runners_yaml, args)
log.dbg(f'runner_config: {runner_config}', level=log.VERBOSE_VERY)
# Use that RunnerConfig to create the ZephyrBinaryRunner instance
# and call its run().
try:
runner = runner_cls.create(runner_config, args)
runner.run(command_name)
except ValueError as ve:
log.err(str(ve), fatal=True)
dump_traceback()
raise CommandError(1)
except MissingProgram as e:
log.die('required program', e.filename,
'not found; install it or add its location to PATH')
except RuntimeError as re:
if not user_args.verbose:
log.die(re)
else:
log.err('verbose mode enabled, dumping stack:', fatal=True)
raise
return runner
def get_build_dir(args, die_if_none=True):
# Get the build directory for the given argument list and environment.
if args.build_dir:
return args.build_dir
guess = config.get('build', 'guess-dir', fallback='never')
guess = guess == 'runners'
dir = find_build_dir(None, guess)
if dir and is_zephyr_build(dir):
return dir
elif die_if_none:
msg = '--build-dir was not given, '
if dir:
msg = msg + 'and neither {} nor {} are zephyr build directories.'
else:
msg = msg + ('{} is not a build directory and the default build '
'directory cannot be determined. Check your '
'build.dir-fmt configuration option')
log.die(msg.format(getcwd(), dir))
else:
return None
def load_cmake_cache(build_dir, args):
cache_file = path.join(build_dir, args.cmake_cache or zcmake.DEFAULT_CACHE)
try:
return zcmake.CMakeCache(cache_file)
except FileNotFoundError:
log.die(f'no CMake cache found (expected one at {cache_file})')
def rebuild(command, build_dir, args):
_banner(f'west {command.name}: rebuilding')
try:
zcmake.run_build(build_dir)
except CalledProcessError:
if args.build_dir:
log.die(f're-build in {args.build_dir} failed')
else:
log.die(f're-build in {build_dir} failed (no --build-dir given)')
def runners_yaml_path(build_dir, board):
ret = Path(build_dir) / 'zephyr' / 'runners.yaml'
if not ret.is_file():
log.die(f'no runners.yaml found in {build_dir}/zephyr. '
f"Either board {board} doesn't support west flash/debug/simulate,"
' or a pristine build is needed.')
return ret
def load_runners_yaml(path):
# Load runners.yaml and convert to Python object.
try:
with open(path, 'r') as f:
content = yaml.safe_load(f.read())
except FileNotFoundError:
log.die(f'runners.yaml file not found: {path}')
if not content.get('runners'):
log.wrn(f'no pre-configured runners in {path}; '
"this probably won't work")
return content
def use_runner_cls(command, board, args, runners_yaml, cache):
# Get the ZephyrBinaryRunner class from its name, and make sure it
# supports the command. Print a message about the choice, and
# return the class.
runner = args.runner or runners_yaml.get(command.runner_key)
if runner is None:
log.die(f'no {command.name} runner available for board {board}. '
"Check the board's documentation for instructions.")
_banner(f'west {command.name}: using runner {runner}')
available = runners_yaml.get('runners', [])
if runner not in available:
if 'BOARD_DIR' in cache:
board_cmake = Path(cache['BOARD_DIR']) / 'board.cmake'
else:
board_cmake = 'board.cmake'
log.err(f'board {board} does not support runner {runner}',
fatal=True)
log.inf(f'To fix, configure this runner in {board_cmake} and rebuild.')
sys.exit(1)
try:
runner_cls = get_runner_cls(runner)
except ValueError as e:
log.die(e)
if command.name not in runner_cls.capabilities().commands:
log.die(f'runner {runner} does not support command {command.name}')
return runner_cls
def get_runner_config(build_dir, yaml_path, runners_yaml, args=None):
# Get a RunnerConfig object for the current run. yaml_config is
# runners.yaml's config: map, and args are the command line arguments.
yaml_config = runners_yaml['config']
yaml_dir = yaml_path.parent
if args is None:
args = argparse.Namespace()
def output_file(filetype):
from_args = getattr(args, f'{filetype}_file', None)
if from_args is not None:
return from_args
from_yaml = yaml_config.get(f'{filetype}_file')
if from_yaml is not None:
# Output paths in runners.yaml are relative to the
# directory containing the runners.yaml file.
return fspath(yaml_dir / from_yaml)
return None
def config(attr, default=None):
return getattr(args, attr, None) or yaml_config.get(attr, default)
def filetype(attr):
ftype = str(getattr(args, attr, None)).lower()
if ftype == "hex":
return FileType.HEX
elif ftype == "bin":
return FileType.BIN
elif ftype == "elf":
return FileType.ELF
elif getattr(args, attr, None) is not None:
err = 'unknown --file-type ({}). Please use hex, bin or elf'
raise ValueError(err.format(ftype))
# file-type not provided, try to get from filename
file = getattr(args, "file", None)
if file is not None:
ext = Path(file).suffix
if ext == ".hex":
return FileType.HEX
if ext == ".bin":
return FileType.BIN
if ext == ".elf":
return FileType.ELF
# we couldn't get the file-type, set to
# OTHER and let the runner deal with it
return FileType.OTHER
return RunnerConfig(build_dir,
yaml_config['board_dir'],
output_file('elf'),
output_file('exe'),
output_file('hex'),
output_file('bin'),
output_file('uf2'),
config('file'),
filetype('file_type'),
config('gdb'),
config('openocd'),
config('openocd_search', []),
config('rtt_address'))
def dump_traceback():
# Save the current exception to a file and return its path.
fd, name = tempfile.mkstemp(prefix='west-exc-', suffix='.txt')
close(fd) # traceback has no use for the fd
with open(name, 'w') as f:
traceback.print_exc(file=f)
log.inf("An exception trace has been saved in", name)
#
# west {command} --context
#
def dump_context(command, args, unknown_args):
build_dir = get_build_dir(args, die_if_none=False)
if build_dir is None:
log.wrn('no --build-dir given or found; output will be limited')
runners_yaml = None
else:
build_conf = BuildConfiguration(build_dir)
board = build_conf.get('CONFIG_BOARD_TARGET')
yaml_path = runners_yaml_path(build_dir, board)
runners_yaml = load_runners_yaml(yaml_path)
# Re-build unless asked not to, to make sure the output is up to date.
if build_dir and not args.skip_rebuild:
rebuild(command, build_dir, args)
if args.runner:
try:
cls = get_runner_cls(args.runner)
except ValueError:
log.die(f'invalid runner name {args.runner}; choices: ' +
', '.join(cls.name() for cls in
ZephyrBinaryRunner.get_runners()))
else:
cls = None
if runners_yaml is None:
dump_context_no_config(command, cls)
else:
log.inf(f'build configuration:', colorize=True)
log.inf(f'{INDENT}build directory: {build_dir}')
log.inf(f'{INDENT}board: {board}')
log.inf(f'{INDENT}runners.yaml: {yaml_path}')
if cls:
dump_runner_context(command, cls, runners_yaml)
else:
dump_all_runner_context(command, runners_yaml, board, build_dir)
def dump_context_no_config(command, cls):
if not cls:
all_cls = {cls.name(): cls for cls in ZephyrBinaryRunner.get_runners()
if command.name in cls.capabilities().commands}
log.inf('all Zephyr runners which support {}:'.format(command.name),
colorize=True)
dump_wrapped_lines(', '.join(all_cls.keys()), INDENT)
log.inf()
log.inf('Note: use -r RUNNER to limit information to one runner.')
else:
# This does the right thing with a None argument.
dump_runner_context(command, cls, None)
def dump_runner_context(command, cls, runners_yaml, indent=''):
dump_runner_caps(cls, indent)
dump_runner_option_help(cls, indent)
if runners_yaml is None:
return
if cls.name() in runners_yaml['runners']:
dump_runner_args(cls.name(), runners_yaml, indent)
else:
log.wrn(f'support for runner {cls.name()} is not configured '
f'in this build directory')
def dump_runner_caps(cls, indent=''):
# Print RunnerCaps for the given runner class.
log.inf(f'{indent}{cls.name()} capabilities:', colorize=True)
log.inf(f'{indent}{INDENT}{cls.capabilities()}')
def dump_runner_option_help(cls, indent=''):
# Print help text for class-specific command line options for the
# given runner class.
dummy_parser = argparse.ArgumentParser(prog='', add_help=False, allow_abbrev=False)
cls.add_parser(dummy_parser)
formatter = dummy_parser._get_formatter()
for group in dummy_parser._action_groups:
# Break the abstraction to filter out the 'flash', 'debug', etc.
# TODO: come up with something cleaner (may require changes
# in the runner core).
actions = group._group_actions
if len(actions) == 1 and actions[0].dest == 'command':
# This is the lone positional argument. Skip it.
continue
formatter.start_section('REMOVE ME')
formatter.add_text(group.description)
formatter.add_arguments(actions)
formatter.end_section()
# Get the runner help, with the "REMOVE ME" string gone
runner_help = f'\n{indent}'.join(formatter.format_help().splitlines()[1:])
log.inf(f'{indent}{cls.name()} options:', colorize=True)
log.inf(indent + runner_help)
def dump_runner_args(group, runners_yaml, indent=''):
msg = f'{indent}{group} arguments from runners.yaml:'
args = runners_yaml['args'][group]
if args:
log.inf(msg, colorize=True)
for arg in args:
log.inf(f'{indent}{INDENT}{arg}')
else:
log.inf(f'{msg} (none)', colorize=True)
def dump_all_runner_context(command, runners_yaml, board, build_dir):
all_cls = {cls.name(): cls for cls in ZephyrBinaryRunner.get_runners() if
command.name in cls.capabilities().commands}
available = runners_yaml['runners']
available_cls = {r: all_cls[r] for r in available if r in all_cls}
default_runner = runners_yaml[command.runner_key]
yaml_path = runners_yaml_path(build_dir, board)
runners_yaml = load_runners_yaml(yaml_path)
log.inf(f'zephyr runners which support "west {command.name}":',
colorize=True)
dump_wrapped_lines(', '.join(all_cls.keys()), INDENT)
log.inf()
dump_wrapped_lines('Note: not all may work with this board and build '
'directory. Available runners are listed below.',
INDENT)
log.inf(f'available runners in runners.yaml:',
colorize=True)
dump_wrapped_lines(', '.join(available), INDENT)
log.inf(f'default runner in runners.yaml:', colorize=True)
log.inf(INDENT + default_runner)
log.inf('common runner configuration:', colorize=True)
runner_config = get_runner_config(build_dir, yaml_path, runners_yaml)
for field, value in zip(runner_config._fields, runner_config):
log.inf(f'{INDENT}- {field}: {value}')
log.inf('runner-specific context:', colorize=True)
for cls in available_cls.values():
dump_runner_context(command, cls, runners_yaml, INDENT)
if len(available) > 1:
log.inf()
log.inf('Note: use -r RUNNER to limit information to one runner.')
def dump_wrapped_lines(text, indent):
for line in textwrap.wrap(text, initial_indent=indent,
subsequent_indent=indent,
break_on_hyphens=False,
break_long_words=False):
log.inf(line)