From ae76d7f364d2d15e8cffd7e1fa82d73060323498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABtan=20Harter?= <gaetan.harter@fu-berlin.de> Date: Fri, 18 Jan 2019 16:00:21 +0100 Subject: [PATCH] dist/tools/compile_and_test_for_board: add compile and test script Move the compile and test script from Release-Specs. https://github.com/RIOT-OS/Release-Specs/blob/271dc8/02-tests/compile_and_test_for_board.py By default it should be run as ./compile_and_test_for_board.py path_to_riot_directory board_name [results] The script is migrated as-is so has not been changed to automatically use the current repository. --- .../compile_and_test_for_board/README.md | 21 + .../compile_and_test_for_board.py | 629 ++++++++++++++++++ 2 files changed, 650 insertions(+) create mode 100644 dist/tools/compile_and_test_for_board/README.md create mode 100755 dist/tools/compile_and_test_for_board/compile_and_test_for_board.py diff --git a/dist/tools/compile_and_test_for_board/README.md b/dist/tools/compile_and_test_for_board/README.md new file mode 100644 index 0000000000..710efced86 --- /dev/null +++ b/dist/tools/compile_and_test_for_board/README.md @@ -0,0 +1,21 @@ +Compile and Test one board +========================== + + +The `./compile_and_test_for_board.py` script can be used to run all compilation +and **automated** tests for one board, not the manual tests. + + +Usage +----- + + ./compile_and_test_for_board.py path_to_riot_directory board_name [results] + +It prints the summary with results files relative to `results_dir/board` to have +a github friendly output. + +Failures and all tests output are saved in files. +They can be checked with: + + find results/ -name '*.failed' + find results/ -name 'test.success' diff --git a/dist/tools/compile_and_test_for_board/compile_and_test_for_board.py b/dist/tools/compile_and_test_for_board/compile_and_test_for_board.py new file mode 100755 index 0000000000..f6adc1f0ba --- /dev/null +++ b/dist/tools/compile_and_test_for_board/compile_and_test_for_board.py @@ -0,0 +1,629 @@ +#! /usr/bin/env python3 + +""" +This script handles building all applications and tests for one board and also +execute tests if they are available. + +An incremental build can selected using `--incremental` to not rerun successful +compilation and tests. But then it should be run on a fixed version of the +repository as no verification is done if results are up to date with the RIOT +repository. + +It by defaults finds all tests in `examples` and `tests` but list of tests can +be provided by command line options with also an exclude list, to for example +not rerun a long failing test every time. + + +It is a temporary solution until an equivalent is implemented in the build +system. It is also a showcase of what could be dummy file output for +compilation and tests. + + +Example +------- + +By default it should be run as + + ./compile_and_test_for_board.py path_to_riot_directory board_name [results] + + +Usage +----- + + +``` +usage: compile_and_test_for_board.py [-h] [--applications APPLICATIONS] + [--applications-exclude + APPLICATIONS_EXCLUDE] + [--no-test] + [--loglevel {debug,info,warning,error, + fatal,critical}] + [--incremental] [--clean-after] + [--jobs JOBS] + riot_directory board [result_directory] + +positional arguments: + riot_directory RIOT directory to test + board Board to test + result_directory Result directory, by default "results" + +optional arguments: + -h, --help show this help message and exit + --applications APPLICATIONS + List of applications to test, overwrites default + configuration of testing all applications + --applications-exclude APPLICATIONS_EXCLUDE + List of applications to exclude from tested + applications. Also applied after "--applications". + --no-test Disable executing tests + --loglevel {debug,info,warning,error,fatal,critical} + Python logger log level, defauts to "info" + --incremental Do not rerun successful compilation and tests + --clean-after Clean after running each test + --jobs JOBS, -j JOBS Parallel building (0 means not limit, like '--jobs') +``` +""" + +import os +import sys +import glob +import shutil +import logging +import argparse +import subprocess +import collections + +LOG_HANDLER = logging.StreamHandler() +LOG_HANDLER.setFormatter(logging.Formatter(logging.BASIC_FORMAT)) + +LOG_LEVELS = ('debug', 'info', 'warning', 'error', 'fatal', 'critical') + + +class TestError(Exception): + """Custom exception for a failed test. + + It contains the step that failed in 'message', the 'application' and the + 'errorfile' path to the execution error. + """ + def __init__(self, message, application, errorfile): + super().__init__(message) + self.application = application + self.errorfile = errorfile + + +def apps_directories(riotdir, apps_dirs=None, apps_dirs_skip=None): + """Return sorted list of test directories relative to `riotdir`. + + By default it uses RIOT 'info-applications' command to list them. + + :param riotdir: base riot directory + :param apps_dirs: use this applications list instead of the RIOT list + :param apps_dirs_skip: list of application directories to remove, applied + on the RIOT list or `apps_dirs` + """ + apps_dirs = apps_dirs or _riot_tracked_applications_dirs(riotdir) + apps_dirs_skip = apps_dirs_skip or [] + + # Remove applications to skip + apps_dirs = set(apps_dirs) - set(apps_dirs_skip) + + return sorted(list(apps_dirs)) + + +def _is_git_repo(riotdir): + """Check if directory is a git repository.""" + cmd = ['git', 'rev-parse', '--git-dir'] + ret = subprocess.call(cmd, cwd=riotdir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + return ret == 0 + + +def _is_git_tracked(appdir): + """Check if directory is a git repository.""" + cmd = ['git', 'ls-files', '--error-unmatch', 'Makefile'] + ret = subprocess.call(cmd, cwd=appdir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + return ret == 0 + + +def _riot_tracked_applications_dirs(riotdir): + """Applications directories in the RIOT repository with relative path. + + Only return 'tracked' applications if riotdir is a git repository. + """ + apps_dirs = _riot_applications_dirs(riotdir) + + # Only keep tracked directories + if _is_git_repo(riotdir): + apps_dirs = [dir for dir in apps_dirs + if _is_git_tracked(os.path.join(riotdir, dir))] + return apps_dirs + + +def _riot_applications_dirs(riotdir): + """Applications directories in the RIOT repository with relative path.""" + cmd = ['make', 'info-applications'] + + out = subprocess.check_output(cmd, cwd=riotdir) + out = out.decode('utf-8', errors='replace') + return out.split() + + +def check_is_board(riotdir, board): + """Verify if board is a RIOT board. + + :raises ValueError: on invalid board + :returns: board name + """ + if board == 'common': + raise ValueError("'%s' is not a board" % board) + board_dir = os.path.join(riotdir, 'boards', board) + if not os.path.isdir(board_dir): + raise ValueError("Cannot find '%s' in %s/boards" % (board, riotdir)) + return board + + +def create_directory(directory, clean=False, mode=0o755): + """Directory creation helper with `clean` option. + + :param clean: tries deleting the directory before re-creating it + """ + if clean: + try: + shutil.rmtree(directory) + except OSError: + pass + os.makedirs(directory, mode=mode, exist_ok=True) + + +class RIOTApplication(): + """RIOT Application representation. + + Allows calling make commands on an application for a board. + + :param board: board name + :param riotdir: RIOT repository directory + :param appdir: directory of the application, can be relative to riotdir + :param resultdir: base directory where to put execution results + """ + + MAKEFLAGS = ('RIOT_CI_BUILD=1', 'CC_NOCOLOR=1', '--no-print-directory') + + COMPILE_TARGETS = ('clean', 'all',) + TEST_TARGETS = ('test',) + + def __init__(self, board, riotdir, appdir, resultdir): + self.board = board + self.riotdir = riotdir + self.appdir = appdir + self.resultdir = os.path.join(resultdir, appdir) + self.logger = logging.getLogger('%s.%s' % (board, appdir)) + + # Extract values from make + def name(self): + """Get application name.""" + appname = self.make(['info-debug-variable-APPLICATION'], + log_error=True).strip() + self.logger.debug('APPLICATION: %s', appname) + return appname + + def has_test(self): + """Detect if the application has tests. + + Use '--silent' to disable the message from packages: + + make[1]: Nothing to be done for 'Makefile.include' + """ + tests = self.make(['--silent', 'info-debug-variable-TESTS'], + log_error=True).strip() + return bool(tests) + + def board_is_supported(self): + """Return if current board is supported.""" + env = {'BOARDS': self.board} + cmd = ['info-boards-supported'] + ret = self.make(cmd, env=env, log_error=True).strip() + + supported = ret == self.board + self.logger.info('Board supported: %s', supported) + return supported + + def board_has_enough_memory(self): + """Return if current board has enough memory.""" + cmd = ['info-debug-variable-BOARD_INSUFFICIENT_MEMORY'] + boards = self.make(cmd, log_error=True).strip().split() + + has_enough_memory = self.board not in boards + self.logger.info('Board has enough memory: %s', has_enough_memory) + return has_enough_memory + + def clean(self): + """Clean build and packages.""" + try: + cmd = ['clean', 'clean-pkg'] + self.make(cmd) + except subprocess.CalledProcessError as err: + self.logger.warning('Got an error during clean, ignore: %r', err) + + def clean_intermediates(self): + """Clean intermediates only.""" + try: + cmd = ['clean-intermediates'] + self.make(cmd) + except subprocess.CalledProcessError as err: + self.logger.warning('Got an error during clean-intermediates,' + ' ignore: %r', err) + + def run_compilation_and_test(self, **test_kwargs): + """Same as `compilation_and_test` but handles exception. + + :returns: 0 on success and 1 on error. + """ + try: + self.compilation_and_test(**test_kwargs) + return None + except TestError as err: + self.logger.error('Failed during: %s', err) + return (str(err), err.application.appdir, err.errorfile) + + def compilation_and_test(self, clean_after=False, runtest=True, + incremental=False, jobs=False): + """Compile and execute test if available. + + Checks for board supported/enough memory, compiles. + If there are tests, also flash the device and run them. + + Output files are written in `self.resultdir` + + When `clean_after` is set, clean intermediates files not required for + the possible following steps. It keeps the elffile after compiling in + case test would be run later and does a full clean after the test + succeeds. + + :param incremental: Do not rerun successful compilation and tests + :raises TestError: on execution failed during one step + """ + + # Ignore incompatible APPS + if not self.board_is_supported(): + create_directory(self.resultdir, clean=True) + self._write_resultfile('skip', 'not_supported') + return + + if not self.board_has_enough_memory(): + create_directory(self.resultdir, clean=True) + self._write_resultfile('skip', 'not_enough_memory') + return + + # Normal case for supported apps + create_directory(self.resultdir, clean=not incremental) + + # Run compilation and flash+test + # It raises TestError on error which is handled outside + + compilation_cmd = list(self.COMPILE_TARGETS) + if jobs is not None: + compilation_cmd += ['--jobs'] + if jobs: + compilation_cmd += [str(jobs)] + self.make_with_outfile('compilation', compilation_cmd) + if clean_after: + self.clean_intermediates() + + if runtest: + if self.has_test(): + setuptasks = collections.OrderedDict( + [('flash', ['flash-only'])]) + self.make_with_outfile('test', self.TEST_TARGETS, + save_output=True, setuptasks=setuptasks) + if clean_after: + self.clean() + else: + self._write_resultfile('test', 'skip.no_test') + + self.logger.info('Success') + + def make(self, args, env=None, log_error=False): + """Run make command in appdir.""" + env = env or {} + # HACK: BOARD should be set for make in environment and not command + # line either it break the `BOARD=none` for global commands + env['BOARD'] = self.board + + full_env = os.environ.copy() + full_env.update(env) + + cmd = ['make'] + cmd.extend(self.MAKEFLAGS) + cmd.extend(['-C', os.path.join(self.riotdir, self.appdir)]) + cmd.extend(args) + + self.logger.debug('%r ENV %s', cmd, env) + # Call without 'universal_newlines' to have bytes and handle decoding + # (encoding and errors are only supported after python 3.6) + try: + out = subprocess.check_output(cmd, env=full_env, + stderr=subprocess.STDOUT) + out = out.decode('utf-8', errors='replace') + except subprocess.CalledProcessError as err: + err.output = err.output.decode('utf-8', errors='replace') + if log_error: + self.logger.error('Error during command: \n%s', err.output) + raise err + return out + + def make_with_outfile(self, name, args, save_output=False, + setuptasks=None): + """Run make but save result in an outfile. + + It will be saved in `self.resultdir/name.[success|failure]`. + + :param name: basename to use for the result file. + :param save_output: output should be saved in the outfile and returned, + if not, return an empty string. + :param setuptasks: OrderedDict of tasks to run before the main one + """ + self.logger.info('Run %s', name) + setuptasks = setuptasks or {} + + # Do not re-run if success + output = self._make_get_previous_output(name) + if output is not None: + return output + + # Run setup-tasks, output is only kept in case of error + for taskname, taskargs in setuptasks.items(): + taskname = '%s.%s' % (name, taskname) + self.logger.info('Run %s', taskname) + try: + self.make(taskargs) + except subprocess.CalledProcessError as err: + self._make_handle_error(taskname, err) + + # Run make command + try: + output = self.make(args) + if not save_output: + output = '' + self._write_resultfile(name, 'success', output) + return output + except subprocess.CalledProcessError as err: + self._make_handle_error(name, err) + + def _make_get_previous_output(self, name): + """Get previous result output for step `name`. + + Returns `output` if it is there, None if not. + """ + try: + with open(self._outfile('%s.success' % name), + encoding='utf-8') as outputfd: + self.logger.info('Nothing to be done for %s', name) + return outputfd.read() + except OSError: + pass + return None + + def _make_handle_error(self, name, err): + """Handle exception during make step `name`.""" + output = ' '.join(err.cmd) + '\n' + output += err.output + '\n' + output += 'Return value: %s\n' % err.returncode + outfile = self._write_resultfile(name, 'failed', output) + + self.logger.warning(output) + self.logger.error('Error during %s, writing to %s', name, outfile) + raise TestError(name, self, outfile) + + def _write_resultfile(self, name, status, body=''): + """Write `body` to result file `name.status`. + + It also deletes other `name.*` files before. + """ + + # Delete previous status files + resultfiles = glob.glob(self._outfile('%s.*' % name)) + for resultfile in resultfiles: + try: + os.remove(resultfile) + except OSError: + pass + + # Create new file + filename = '%s.%s' % (name, status) + outfile = self._outfile(filename) + + with open(outfile, 'w+', encoding='utf-8', + errors='replace') as outfd: + outfd.write(body) + outfd.flush() + return outfile + + def _outfile(self, filename): + """Give path to `filename` with `self.resultdir`.""" + return os.path.join(self.resultdir, filename) + + +TOOLCHAIN_SCRIPT = 'dist/tools/ci/print_toolchain_versions.sh' + + +def print_toolchain(riotdir): + """Print toolchain using RIOT script. + + Does not handle any execution error + """ + toolchain_script = os.path.join(riotdir, TOOLCHAIN_SCRIPT) + out = subprocess.check_output([toolchain_script]) + return out.decode('utf-8', errors='replace') + + +def save_toolchain(riotdir, resultdir): + """Save toolchain in 'resultdir/toolchain'.""" + outfile = os.path.join(resultdir, 'toolchain') + create_directory(resultdir) + + toolchain = print_toolchain(riotdir) + with open(outfile, 'w+', encoding='utf-8', errors='replace') as outputfd: + outputfd.write(toolchain) + + +def _test_failed_summary(errors, relpathstart=None): + """Generate a test summary for failures.""" + if not errors: + return '' + + errors_dict = {} + for step, appdir, errorfile in errors: + if relpathstart: + errorfile = os.path.relpath(errorfile, relpathstart) + errors_dict.setdefault(step, []).append((appdir, errorfile)) + + summary = '' + for step, errs in sorted(errors_dict.items()): + summary += 'Failures during %s:\n' % step + for appdir, errorfile in errs: + summary += '- [%s](%s)\n' % (appdir, errorfile) + # Separate sections with a new line + summary += '\n' + + # Remove last new line + summary = summary[:-1] + return summary + + +def save_failure_summary(resultdir, summary): + """Save test summary in 'resultdir/board/failuresummary'.""" + outfile = os.path.join(resultdir, 'failuresummary.md') + + with open(outfile, 'w+', encoding='utf-8', errors='replace') as outputfd: + outputfd.write(summary) + + +# Parsing functions + + +def list_from_string(list_str=None): + """Get list of items from `list_str` + + >>> list_from_string(None) + [] + >>> list_from_string("") + [] + >>> list_from_string(" ") + [] + >>> list_from_string("a") + ['a'] + >>> list_from_string("a ") + ['a'] + >>> list_from_string("a b c") + ['a', 'b', 'c'] + """ + value = (list_str or '').split(' ') + return [v for v in value if v] + + +def _strip_board_equal(board): + """Sanitizy board if given as BOARD=board. + + Increase RIOT compatibility. + """ + if board.startswith('BOARD='): + board = board.replace('BOARD=', '') + return board + + +PARSER = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter) +PARSER.add_argument('riot_directory', help='RIOT directory to test') +PARSER.add_argument('board', help='Board to test', type=_strip_board_equal) +PARSER.add_argument('result_directory', nargs='?', default='results', + help='Result directory, by default "results"') +PARSER.add_argument( + '--applications', type=list_from_string, + help=('List of applications to test, overwrites default configuration of' + ' testing all applications'), +) +PARSER.add_argument( + '--applications-exclude', type=list_from_string, + help=('List of applications to exclude from tested applications.' + ' Also applied after "--applications".'), +) +PARSER.add_argument('--no-test', action='store_true', default=False, + help='Disable executing tests') +PARSER.add_argument('--loglevel', choices=LOG_LEVELS, default='info', + help='Python logger log level, defauts to "info"') +PARSER.add_argument('--incremental', action='store_true', default=False, + help='Do not rerun successful compilation and tests') +PARSER.add_argument('--clean-after', action='store_true', default=False, + help='Clean after running each test') + +PARSER.add_argument('--compile-targets', type=list_from_string, + default=' '.join(RIOTApplication.COMPILE_TARGETS), + help='List of make targets to compile') +PARSER.add_argument('--test-targets', type=list_from_string, + default=' '.join(RIOTApplication.TEST_TARGETS), + help='List of make targets to run test') + +PARSER.add_argument( + '--jobs', '-j', type=int, default=None, + help="Parallel building (0 means not limit, like '--jobs')") + + +def main(): + """For one board, compile all examples and tests and run test on board.""" + args = PARSER.parse_args() + + logger = logging.getLogger(args.board) + if args.loglevel: + loglevel = logging.getLevelName(args.loglevel.upper()) + logger.setLevel(loglevel) + + logger.addHandler(LOG_HANDLER) + + logger.info('Saving toolchain') + save_toolchain(args.riot_directory, args.result_directory) + + board = check_is_board(args.riot_directory, args.board) + logger.debug('board: %s', board) + + app_dirs = apps_directories(args.riot_directory, + apps_dirs=args.applications, + apps_dirs_skip=args.applications_exclude) + + logger.debug('app_dirs: %s', app_dirs) + logger.debug('resultdir: %s', args.result_directory) + board_result_directory = os.path.join(args.result_directory, args.board) + + # Overwrite the compile/test targets from command line arguments + RIOTApplication.COMPILE_TARGETS = args.compile_targets + RIOTApplication.TEST_TARGETS = args.test_targets + + # List of applications for board + applications = [RIOTApplication(board, args.riot_directory, app_dir, + board_result_directory) + for app_dir in app_dirs] + + # Execute tests + errors = [app.run_compilation_and_test(clean_after=args.clean_after, + runtest=not args.no_test, + incremental=args.incremental, + jobs=args.jobs) + for app in applications] + errors = [e for e in errors if e is not None] + num_errors = len(errors) + + summary = _test_failed_summary(errors, relpathstart=board_result_directory) + save_failure_summary(board_result_directory, summary) + + if num_errors: + logger.error('Tests failed: %d', num_errors) + print(summary, end='') + else: + logger.info('Tests successful') + sys.exit(num_errors) + + +if __name__ == '__main__': + main() -- GitLab