blob: 8e26dfca1d29c44f597c06f7e01e936d7e25ddeb [file] [log] [blame]
#!/usr/bin/env python3
# Copyright (c) 2020 Project CHIP Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Utitilies to flash or erase a device."""
import argparse
import pathlib
import errno
import locale
import os
import stat
import subprocess
import sys
import textwrap
# Here are the options that can be use to configure a `Flasher`
# object (as dictionary keys) and/or passed as command line options.
OPTIONS = {
# Configuration options define properties used in flashing operations.
# (The outer level of an options definition corresponds to option groups
# in the command-line help message.)
'configuration': {
# Script configuration options.
'verbose': {
'help': 'Report more verbosely',
'default': 0,
'alias': ['-v'],
'argparse': {
'action': 'count',
},
# Levels:
# 0 - error message
# 1 - action to be taken
# 2 - results of action, even if successful
# 3+ - details
},
},
# Action control options specify operations that Flasher.action() or
# the function interface flash_command() will perform.
'operations': {
# Action control options.
'erase': {
'help': 'Erase device',
'default': False,
'argparse': {
'action': 'store_true'
},
},
'application': {
'help': 'Flash an image',
'default': None,
'argparse': {
'metavar': 'FILE',
'type': pathlib.Path,
},
},
'verify_application': {
'help': 'Verify the image after flashing',
'default': False,
'argparse': {
'action': 'store_true'
},
},
# 'reset' is a three-way switch; if None, action() will reset the
# device if and only if an application image is flashed. So, we add
# an explicit option to set it false.
'reset': {
'help': 'Reset device after flashing',
'default': None, # None = Reset iff application was flashed.
'argparse': {
'action': 'store_true'
},
},
'skip_reset': {
'help': 'Do not reset device after flashing',
'default': None, # None = Reset iff application was flashed.
'argparse': {
'dest': 'reset',
'action': 'store_false'
},
}
},
# Internal; these properties do not have command line options
# (because they don't have an `argparse` key).
'internal': {
# Script configuration options.
'platform': {
'help': 'Short name of the current platform',
'default': None,
},
'module': {
'help': 'Invoking Python module, for generating scripts',
'default': None,
},
},
}
class Flasher:
"""Manage flashing."""
def __init__(self, **options):
# An integer giving the current Flasher status.
# 0 if OK, and normally an errno value if positive.
self.err = 0
# Namespace of option values.
self.option = argparse.Namespace(**options)
# Namespace of option metadata. This contains the option specification
# information one level down from `define_options()`, i.e. without the
# group; the keys are mostly the same as those of `self.option`.
# (Exceptions include options with no metadata and only defined when
# constructing the Flasher, and options where different command line
# options (`info` keys) affect a single attribute (e.g. `reset` and
# `skip-reset` have distinct `info` entries but one option).
self.info = argparse.Namespace()
# `argv[0]` from the most recent call to parse_argv(); that is,
# the path used to invoke the script. This is used to find files
# relative to the script.
self.argv0 = None
# Argument parser for `parse_argv()`. Normally defines command-line
# options for most of the `self.option` keys.
self.parser = argparse.ArgumentParser(
description='Flash {} device'.format(self.option.platform or 'a'))
# Argument parser groups.
self.group = {}
# Construct the global options for all Flasher()s.
self.define_options(OPTIONS)
def define_options(self, options):
"""Define options, including setting defaults and argument parsing."""
for group, group_options in options.items():
if group not in self.group:
self.group[group] = self.parser.add_argument_group(group)
for key, info in group_options.items():
setattr(self.info, key, info)
if 'argparse' not in info:
continue
argument = info['argparse']
attribute = argument.get('dest', key)
# Set default value.
if attribute not in self.option:
setattr(self.option, attribute, info['default'])
# Add command line argument.
names = ['--' + key]
if '_' in key:
names.append('--' + key.replace('_', '-'))
if 'alias' in info:
names += info['alias']
self.group[group].add_argument(
*names,
help=info['help'],
default=getattr(self.option, attribute),
**argument)
return self
def status(self):
"""Return the current error code."""
return self.err
def actions(self):
"""Perform actions on the device according to self.option."""
raise NotImplementedError()
def log(self, level, *args):
"""Optionally log a message to stderr."""
if self.option.verbose >= level:
print(*args, file=sys.stderr)
def run_tool(self,
tool,
arguments,
options=None,
name=None,
pass_message=None,
fail_message=None,
fail_level=0,
capture_output=False):
"""Run an external tool."""
if name is None:
name = 'Run ' + tool
self.log(1, name)
option_map = vars(self.option)
if options:
option_map.update(options)
arguments = self.format_command(arguments, opt=option_map)
if not getattr(self.option, tool, None):
setattr(self.option, tool, self.locate_tool(tool))
tool_info = getattr(self.info, tool)
command_template = tool_info.get('command', ['{' + tool + '}', ()])
command = self.format_command(command_template, arguments, option_map)
self.log(3, 'Execute:', *command)
try:
if capture_output:
result = None
result = subprocess.run(
command,
check=True,
encoding=locale.getpreferredencoding(),
capture_output=True)
else:
result = self
self.error = subprocess.check_call(command)
except subprocess.CalledProcessError as exception:
self.err = exception.returncode
if capture_output:
self.log(fail_level, '--- stdout ---')
self.log(fail_level, exception.stdout)
self.log(fail_level, '--- stderr ---')
self.log(fail_level, exception.stderr)
self.log(fail_level, '---')
except FileNotFoundError as exception:
self.err = exception.errno
if self.err == errno.ENOENT:
# This likely means that the program was not found.
# But if it seems OK, rethrow the exception.
if self.verify_tool(tool):
raise exception
if self.err:
self.log(fail_level, fail_message or ('FAILED: ' + name))
else:
self.log(2, pass_message or (name + ' complete'))
return result
def locate_tool(self, tool):
"""Called to find an undefined tool. (Override in platform.)"""
return tool
def verify_tool(self, tool):
"""Run a command to verify that an external tool is available.
Prints a configurable error and returns False if not.
"""
tool_info = getattr(self.info, tool)
command_template = tool_info.get('verify')
if not command_template:
return True
command = self.format_command(command_template, opt=vars(self.option))
try:
self.err = subprocess.call(command)
except OSError as ex:
self.err = ex.errno
if self.err:
note = tool_info.get('error', 'Unable to execute {tool}.')
note = textwrap.dedent(note).format(tool=tool, **vars(self.option))
# textwrap.fill only handles single paragraphs:
note = '\n\n'.join((textwrap.fill(p) for p in note.split('\n\n')))
print(note, file=sys.stderr)
return False
return True
def format_command(self, template, args=None, opt=None):
"""Construct a tool command line.
This provides a few conveniences over a simple list of fixed strings,
that in most cases eliminates any need for custom code to build a tool
command line. In this description, φ(τ) is the result of formatting a
template τ.
template ::= list | () | str | dict
Typically the caller provides a list, and `format_command()` returns a
formatted list. The results of formatting sub-elements get interpolated
into the end result.
list ::= [τ₀, …, τₙ]
↦ φ(τ₀) + … + φ(τₙ)
An empty tuple returns the supplied `args`. Typically this would be
used for things like subcommands or file names at the end of a command.
() ↦ args or []
Formatting a string uses the Python string formatter with the `opt`
map as arguments. Typically used to interpolate an option value into
the command line, e.g. ['--flag', '{flag}'] or ['--flag={flag}'].
str ::= σ
↦ [σ.format_map(opt)]
A dictionary element provides a convenience feature. For any dictionary
template, if it contains an optional 'expand' key that tests true, the
result is recursively passed to format_command(); otherwise it is taken
as is.
The simplest case is an option propagated to the tool command line,
as a single option if the value is exactly boolean True or as an
option-argument pair if otherwise set.
optional ::= {'optional': name}
↦ ['--name'] if opt[name] is True
['--name', opt[name]] if opt[name] tests true
[] otherwise
A dictionary with an 'option' can insert command line arguments based
on the value of an option. The 'result' is optional defaults to the
option value itself, and 'else' defaults to nothing.
option ::= {'option': name, 'result': ρ, 'else': δ}
↦ ρ if opt[name]
δ otherwise
A dictionary with a 'match' key returns a result comparing the value of
an option against a 'test' list of tuples. The 'else' is optional and
defaults to nothing.
match ::= {'match': name, 'test': [(σᵢ, ρᵢ), …], 'else': ρ}
↦ ρᵢ if opt[name]==σᵢ
ρ otherwise
"""
if isinstance(template, str) or isinstance(template, pathlib.Path):
result = [str(template).format_map(opt)]
elif isinstance(template, list):
result = []
for i in template:
result += self.format_command(i, args, opt)
elif template == ():
result = args or []
elif isinstance(template, dict):
if 'optional' in template:
name = template['optional']
value = opt.get(name)
if value is True:
result = ['--' + name]
elif value:
result = ['--' + name, value]
else:
result = []
elif 'option' in template:
name = template['option']
value = opt.get(name)
if value:
result = template.get('result', value)
else:
result = template.get('else')
elif 'match' in template:
value = template['match']
for compare, result in template['test']:
if value == compare:
break
else:
result = template.get('else')
if result and template.get('expand'):
result = self.format_command(result, args, opt)
elif result is None:
result = []
elif not isinstance(result, list):
result = [result]
else:
raise ValueError('Unknown: {}'.format(template))
return result
def parse_argv(self, argv):
"""Handle command line options."""
self.argv0 = argv[0]
self.parser.parse_args(argv[1:], namespace=self.option)
self._postprocess_argv()
return self
def _postprocess_argv(self):
"""Called after parse_argv() for platform-specific processing."""
def flash_command(self, argv):
"""Perform device actions according to the command line."""
return self.parse_argv(argv).actions().status()
def _platform_wrapper_args(self, args):
"""Called from make_wrapper() to optionally manipulate arguments."""
def make_wrapper(self, argv):
"""Generate script to flash a device.
The generated script is a minimal wrapper around `flash_command()`,
containing any option values that differ from the class defaults.
"""
# Note: this modifies the argument parser, so the same Flasher instance
# should not be used for both parse_argv() and make_wrapper().
self.parser.description = 'Generate a flashing script.'
self.parser.add_argument(
'--output',
metavar='FILENAME',
required=True,
help='flashing script name')
self.argv0 = argv[0]
args = self.parser.parse_args(argv[1:])
# Give platform-specific code a chance to manipulate the arguments
# for the wrapper script.
self._platform_wrapper_args(args)
# Find any option values that differ from the class defaults.
# These will be inserted into the wrapper script.
defaults = []
for key, value in vars(args).items():
if key in self.option and value != getattr(self.option, key):
if isinstance(value, pathlib.Path):
defaults.append(' {}: os.path.join(os.path.dirname(sys.argv[0]), {}),'.format(
repr(key), repr(str(value))))
else:
defaults.append(' {}: {},'.format(repr(key), repr(value)))
script = """
import sys
import os.path
DEFAULTS = {{
{defaults}
}}
import {module}
if __name__ == '__main__':
sys.exit({module}.Flasher(**DEFAULTS).flash_command(sys.argv))
"""
script = ('#!/usr/bin/env python3' + textwrap.dedent(script).format(
module=self.option.module, defaults='\n'.join(defaults)))
try:
with open(args.output, 'w') as script_file:
script_file.write(script)
os.chmod(args.output, (stat.S_IXUSR | stat.S_IRUSR | stat.S_IWUSR
| stat.S_IXGRP | stat.S_IRGRP
| stat.S_IXOTH | stat.S_IROTH))
except OSError as exception:
print(exception, sys.stderr)
return 1
return 0