#!/usr/bin/env python """ This script is intended to be used to run make under supervision for the purpose of capturing its stdout/stderr and retcode to generate a JUnit XML report. This report can be used in Jenkins or any other CI. This report contains one test suite and as many cases as many targets are specified. To get help on usage, possible options and their descriptions, use the following command: make.py --help An example of running this script for building a demo project: make.py -C demos/STM32/RT-STM32WB55RG-NUCLEO68 -f Makefile all This script requires the following packages to be installed: pip install junit-xml pip install pyyaml pip install requests """ import argparse import collections import os import re import subprocess import sys import time from xml.etree import ElementTree import junit_xml import requests import yaml def get_jobserver_auth_fds(): """Get "jobserver" file descriptors from MAKEFLAGS.""" make_flags = os.environ.get('MAKEFLAGS', '') jobserver_auth_flag = '--jobserver-auth=' for make_flag in make_flags.split(): if make_flag.startswith(jobserver_auth_flag): fdr, _, fdw = make_flag[len(jobserver_auth_flag):].partition(',') return (int(fdr), int(fdw)) return () class BaseRules: def __init__(self, filename): self.filename = filename self._rules = None def load(self): with open(self.filename) as fd: self._rules = yaml.safe_load(fd) @property def rules(self): if self._rules is None: self.load() return self._rules class SkipRules(BaseRules): def match(self, path, stderr): for rule in self.rules: if (re.match(rule['path'], path) and re.search(rule['stderr'], stderr)): return True, rule['reason'] return False, None class TargetRules(BaseRules): def match(self, path): for rule, targets in self.rules.items(): if re.match(rule, path): return targets return None def get_params(args): directory = args.directory or '' makefile = args.makefile or '' path = os.path.join(directory, makefile) if args.prefix: assert path.startswith(args.prefix) path = path[len(args.prefix):].lstrip(os.path.sep) return Params(path, directory, makefile) Params = collections.namedtuple('Params', [ 'path', 'directory', 'makefile', ]) def get_targets(args, path): if args.target_rules: target_rules = TargetRules(args.target_rules) targets = target_rules.match(path) if targets is not None: return targets return args.targets def make(args): if args.skip_rules: skip_rules = SkipRules(args.skip_rules) params = get_params(args) package = params.path.partition(os.path.sep)[0] suite = junit_xml.TestSuite( params.path, timestamp=time.time(), file=params.path, package=package, ) cmd = ['/usr/bin/env', 'make'] if params.directory: cmd.extend(['-C', params.directory]) if params.makefile: cmd.extend(['-f', params.makefile]) if args.jobs > 1: cmd.extend(['-j', str(args.jobs)]) jobserver_auth_fds = get_jobserver_auth_fds() targets = get_targets(args, params.path) for target in targets: if not target: continue cmd_target = cmd + [target] timestamp=time.time() start = time.monotonic() # To better control the use of resources by a subprocess make, pass # jobserver auth file descriptors in the subprocess, so it will # participate in the jobserver protocol and schedule its jobs # accordingly. ret = subprocess.run(cmd_target, pass_fds=jobserver_auth_fds, capture_output=True, text=True) end = time.monotonic() case_name = 'make ' + target case_log = ' '.join(cmd_target) case_classname = params.path case = junit_xml.TestCase( case_name, classname=case_classname, file=params.path, log=case_log, timestamp=timestamp, elapsed_sec=(end - start), stdout=ret.stdout, stderr=ret.stderr, ) suite.test_cases.append(case) if ret.returncode != 0: skipped, msg = skip_rules.match(params.path, ret.stderr) if skipped: case.add_skipped_info(msg) continue msg = 'Ended with non-zero exit code: {}'.format(ret.returncode) case.add_failure_info(msg) if args.result == '-': suite.to_file(sys.stdout, [suite]) else: test_result_path = os.path.join(args.result, params.path) + '.xml' with open(test_result_path, 'w') as f: suite.to_file(f, [suite]) def check_skip_rules(args): def get_signature(content): root = ElementTree.fromstring(content) return ( root.findtext('className'), root.findtext('stderr'), ) if not args.skip_rules: sys.stderr.write('--skip-rules is required\n') sys.exit(2) skip_rules = SkipRules(args.skip_rules) url = args.check_skip_rules if not url.endswith('/api/xml'): url += '/api/xml' resp = requests.get(url) if resp.status_code != 200: sys.stderr.write('Got unexpected response {}\n' .format(resp.status_code)) sys.exit(3) path, stderr = get_signature(resp.content) if not (path and stderr): sys.stderr.write('Cannot find path and stderr\n') sys.exit(4) matched, msg = skip_rules.match(path, stderr) if matched: sys.stdout.write('Matched rule: {}\n'.format(msg)) else: sys.stdout.write('Not matched!\n') sys.exit(1) def check_target_rules(args): if not args.target_rules: sys.stderr.write('--target-rules is required\n') sys.exit(1) params = get_params(args) targets = get_targets(args, params.path) sys.stdout.write(' '.join(targets)) def main(): parser = argparse.ArgumentParser(description=( 'Execute make targets and generate JUnit results' )) # Make specific arguments parser.add_argument('-C', '--directory', help='Change directory to dir (equivalent to ' '`make -C dir`)') parser.add_argument('-f', '--makefile', help='Use file as a makefile (equivalent to ' '`make -f file`)') parser.add_argument('-j', '--jobs', type=int, default=1, help='Number of simultaneous jobs (equivalent to ' '`make -j jobs`)') # Test specific arguments parser.add_argument('-r', '--result', default='-', help='Directory to store JUnit XML test result') parser.add_argument('-p', '--prefix', help=('Prefix path which should be removed for test ' 'result')) parser.add_argument('-s', '--skip-rules', help='YAML-file with skip rules') parser.add_argument('-S', '--check-skip-rules', help='URL to a test result in Jenkins job') parser.add_argument('-t', '--target-rules', help='YAML-file with target rules') parser.add_argument('-T', '--check-target-rules', action='store_true', help='Print target rules for a given makefile') parser.add_argument('targets', metavar='target', nargs='*', default=['all', 'clean'], help='Names of targets to run') args = parser.parse_args() if args.check_skip_rules: check_skip_rules(args) elif args.check_target_rules: check_target_rules(args) else: make(args) if __name__ == '__main__': main()