| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- #!/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 errno
- import locale
- import os
- import pathlib
- 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
|