| #!/usr/bin/env python3 |
| import argparse |
| import random |
| import os |
| import re |
| import sys |
| import time |
| import subprocess |
| import shlex |
| from pathlib import Path |
| from multiprocessing import Pool |
| |
| import build_utils |
| |
| STATUS_OK = "\033[32mOK\033[0m" |
| STATUS_FAILED = "\033[31mFailed\033[0m" |
| STATUS_SKIPPED = "\033[33mSkipped\033[0m" |
| |
| RET_OK = 0 |
| RET_FAILED = 1 |
| RET_SKIPPED = 2 |
| |
| build_format = '| {:30} | {:40} | {:16} | {:5} |' |
| build_separator = '-' * 95 |
| build_status = [STATUS_OK, STATUS_FAILED, STATUS_SKIPPED] |
| |
| verbose = False |
| parallel_jobs = os.cpu_count() |
| |
| # CI board control lists (used when running under CI) |
| ci_skip_boards = { |
| 'rp2040': [ |
| 'adafruit_feather_rp2040_usb_host', |
| 'adafruit_fruit_jam', |
| 'adafruit_metro_rp2350', |
| 'feather_rp2040_max3421', |
| 'pico_sdk', |
| 'raspberry_pi_pico_w', |
| ], |
| } |
| |
| ci_preferred_boards = { |
| 'rp2040': ['raspberry_pi_pico'], |
| 'samd2x_l2x': ['metro_m0_express'], |
| 'samd5x_e5x': ['metro_m4_express'], |
| 'stm32h7': ['stm32h743eval'] |
| } |
| |
| |
| # ----------------------------- |
| # Helper |
| # ----------------------------- |
| def run_cmd(cmd): |
| if isinstance(cmd, str): |
| raise TypeError("run_cmd expects a list/tuple of args, not a string") |
| args = cmd |
| cmd_display = " ".join(args) |
| r = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| title = f'Command Error: {cmd_display}' |
| if r.returncode != 0: |
| # print build output if failed |
| if os.getenv('GITHUB_ACTIONS'): |
| print(f"::group::{title}") |
| print(r.stdout.decode("utf-8")) |
| print(f"::endgroup::") |
| else: |
| print(title) |
| print(r.stdout.decode("utf-8")) |
| elif verbose: |
| print(cmd_display) |
| print(r.stdout.decode("utf-8")) |
| return r |
| |
| |
| def find_family(board): |
| bsp_dir = Path("hw/bsp") |
| for family_dir in bsp_dir.iterdir(): |
| if family_dir.is_dir(): |
| board_dir = family_dir / 'boards' / board |
| if board_dir.exists(): |
| return family_dir.name |
| return None |
| |
| |
| def get_examples(family): |
| all_examples = [] |
| for d in os.scandir("examples"): |
| if d.is_dir() and 'cmake' not in d.name and 'build_system' not in d.name: |
| for entry in os.scandir(d.path): |
| if entry.is_dir() and 'cmake' not in entry.name: |
| if family != 'espressif' or 'freertos' in entry.name: |
| all_examples.append(d.name + '/' + entry.name) |
| |
| if family == 'espressif': |
| all_examples.append('device/board_test') |
| all_examples.append('device/usbtest') |
| all_examples.append('device/video_capture') |
| all_examples.append('host/device_info') |
| all_examples.sort() |
| return all_examples |
| |
| |
| def resolve_example_target_groups(build_targets, examples, board, extra_defines=()): |
| """Map generic targets onto per-example targets for a filtered build (-e), as ONE |
| GROUP PER REQUESTED TARGET: 'all' -> the example executables, anything else (e.g. |
| tinyusb_metrics) passes through as its own single-entry group. |
| |
| Grouped rather than flattened because each group becomes one `cmake --build |
| --target a b c` invocation: the examples of a group build in parallel (flattening |
| them into one target per invocation serialises the whole leg - measured +39% at |
| -j4 and +220% at -j32 on stm32f407disco), while separate groups stay ordered, so a |
| target that must run after the examples still does. |
| |
| extra_defines are this build's -D tokens: MAX3421_HOST=1 there decides |
| only.txt for the max3421 examples (see build_utils.skip_example). |
| Returns None when no requested example is buildable on this board.""" |
| buildable = [e for e in examples |
| if not build_utils.skip_example(e, board, extra_defines)] |
| if not buildable: |
| return None |
| names = list(dict.fromkeys(e.split('/', 1)[1] for e in buildable)) |
| return [list(names) if t == 'all' else [t] for t in build_targets] |
| |
| |
| _TARGET_HELP_RE = re.compile(r'^([A-Za-z0-9_.+-]+):') |
| # role/name, the only shape resolve_example_target_groups and the CMake target names accept |
| EXAMPLE_RE = re.compile(r'[A-Za-z0-9_]+/[A-Za-z0-9_]+') |
| |
| |
| def parse_target_help(text): |
| """Bare target names out of `cmake --build <dir> --target help`; the Ninja |
| generator prints one '<name>: phony' line per target. Names containing '/' are |
| per-directory utility targets (device/edit_cache) or absolute CMakeFiles paths, |
| never an example target.""" |
| return {m.group(1) for m in map(_TARGET_HELP_RE.match, text.splitlines()) if m} |
| |
| |
| def cmake_registered_targets(build_dir): |
| """The targets CMake actually created in build_dir, or None when that cannot be |
| read. Ground truth: skip.txt/only.txt only mirrors family_filter, so an example |
| the role CMakeLists never lists (or a stale -e name) still looks buildable to it |
| and `cmake --build --target <it>` hard-fails. None keeps the mirror's answer.""" |
| r = subprocess.run(['cmake', '--build', build_dir, '--target', 'help'], |
| stdout=subprocess.PIPE, stderr=subprocess.STDOUT) |
| if r.returncode != 0: |
| return None |
| return parse_target_help(r.stdout.decode('utf-8', 'replace')) or None |
| |
| |
| def print_build_result(board, build_target, status, duration): |
| if isinstance(duration, (int, float)): |
| duration = "{:.2f}s".format(duration) |
| print(build_format.format(board, build_target, build_status[status], duration)) |
| |
| # ----------------------------- |
| # CMake |
| # ----------------------------- |
| def cmake_board(board, build_args, build_name, build_cflags, build_targets, examples=None, defines=()): |
| ret = [0, 0, 0] |
| start_time = time.monotonic() |
| |
| build_dir = f'cmake-build/cmake-build-{build_name or board}' |
| build_flags = [] |
| if build_cflags: |
| build_flags.append('-DCFLAGS_CLI=' + ' '.join(build_cflags)) |
| |
| family = find_family(board) |
| if family == 'espressif': |
| # for espressif, we have to build example individually |
| all_examples = get_examples(family) |
| if examples is not None: |
| all_examples = [e for e in all_examples if e in examples] |
| if not all_examples: |
| print_build_result(board, 'examples (PR filter)', 2, '-') |
| return [0, 0, 1] |
| for example in all_examples: |
| if build_utils.skip_example(example, board, defines): |
| ret[2] += 1 |
| else: |
| rcmd = run_cmd([ |
| 'idf.py', '-C', f'examples/{example}', '-B', f'{build_dir}/{example}', '-GNinja', |
| f'-DBOARD={board}', *build_flags, 'build' |
| ]) |
| ret[0 if rcmd.returncode == 0 else 1] += 1 |
| else: |
| # the skip.txt/only.txt prefilter reads no configure output: answer it first, |
| # so a selection this board builds nothing of costs no cmake run at all |
| if examples is not None: |
| examples = [e for e in examples |
| if not build_utils.skip_example(e, board, defines)] |
| if not examples: |
| print_build_result(board, 'examples (PR filter)', 2, '-') |
| return [0, 0, 1] |
| rcmd = run_cmd(['cmake', 'examples', '-B', build_dir, '-GNinja', |
| f'-DBOARD={board}', '-DCMAKE_BUILD_TYPE=MinSizeRel', '-DLINKERMAP_OPTION=-q -f tinyusb/src', |
| *build_args, *build_flags]) |
| if rcmd.returncode == 0: |
| target_groups = [[t] for t in build_targets] |
| if examples is not None: |
| registered = cmake_registered_targets(build_dir) |
| if registered is not None: |
| kept = [e for e in examples if e.split('/', 1)[1] in registered] |
| for e in examples: |
| if e not in kept: |
| print_build_result(board, f'{e} (no such target)', 2, '-') |
| examples = kept |
| if not examples: |
| print_build_result(board, 'examples (no such target)', 2, '-') |
| return [0, 0, 1] |
| target_groups = resolve_example_target_groups(build_targets, examples, board, defines) |
| if registered is None: |
| # ground truth unavailable, so nothing checked these names against |
| # what CMake created. ninja validates a whole invocation up front: |
| # one unknown name in the batch builds NOTHING, where a target each |
| # builds everything up to it. Give up the parallelism, not the work. |
| target_groups = [[t] for g in target_groups for t in g] |
| cmd = ["cmake", "--build", build_dir, '--parallel', str(parallel_jobs)] |
| for group in target_groups: |
| rcmd = run_cmd(cmd + ['--target'] + group) |
| if rcmd.returncode != 0: |
| break |
| ret[0 if rcmd.returncode == 0 else 1] += 1 |
| |
| print_build_result(board, ','.join(build_targets), 0 if ret[1] == 0 else 1, time.monotonic() - start_time) |
| return ret |
| |
| |
| # ----------------------------- |
| # Make |
| # ----------------------------- |
| def make_one_example(example, board, make_option, build_targets, defines=()): |
| # Check if board is skipped. Make semantics: family.mk decides, not the |
| # family.cmake MCU list (see build_utils.skip_example). |
| if build_utils.skip_example(example, board, defines, build_system='make'): |
| print_build_result(board, example, 2, '-') |
| r = 2 |
| else: |
| start_time = time.monotonic() |
| make_cmd = ["make", "-C", f"examples/{example}", f"BOARD={board}", '-j', str(parallel_jobs)] |
| if make_option: |
| make_cmd += shlex.split(make_option) |
| r = 0 |
| for target in build_targets: |
| build_result = run_cmd(make_cmd + [target]) |
| if build_result.returncode != 0: |
| r = 1 |
| break |
| print_build_result(board, example, r, time.monotonic() - start_time) |
| |
| ret = [0, 0, 0] |
| ret[r] = 1 |
| return ret |
| |
| |
| def make_board(board, build_args, build_targets, examples=None, defines=()): |
| print(build_separator) |
| family = find_family(board); |
| all_examples = get_examples(family) |
| if examples is not None: |
| all_examples = [e for e in all_examples if e in examples] |
| if not all_examples: |
| print_build_result(board, 'examples (PR filter)', 2, '-') |
| return [0, 0, 1] |
| start_time = time.monotonic() |
| ret = [0, 0, 0] |
| if family == 'espressif' or family == 'rp2040': |
| # espressif and rp2040 do not support make, use cmake instead |
| final_status = 2 |
| else: |
| with Pool(processes=os.cpu_count()) as pool: |
| pool_args = list((map(lambda e, b=board, o=f"{build_args}", t=build_targets, d=defines: [e, b, o, t, d], all_examples))) |
| r = pool.starmap(make_one_example, pool_args) |
| # sum all element of same index (column sum) |
| ret = list(map(sum, list(zip(*r)))) |
| final_status = 0 if ret[1] == 0 else 1 |
| print_build_result(board, 'all', final_status, time.monotonic() - start_time) |
| return ret |
| |
| |
| # ----------------------------- |
| # Build Family |
| # ----------------------------- |
| def build_boards_list(boards, build_defines, build_system, build_name, build_cflags, build_targets, examples=None): |
| ret = [0, 0, 0] |
| # the -D tokens are part of the skip.txt/only.txt answer (MAX3421_HOST=1), so |
| # the -e filter has to see them too; sorted+tuple keeps skip_example cacheable |
| defines = tuple(sorted(build_defines)) |
| for b in boards: |
| r = [0, 0, 0] |
| if build_system == 'cmake': |
| build_args = [f'-D{d}' for d in build_defines] |
| r = cmake_board(b, build_args, build_name, build_cflags, build_targets, examples, defines) |
| elif build_system == 'make': |
| build_args = ' '.join(f'{d}' for d in build_defines) |
| r = make_board(b, build_args, build_targets, examples, defines) |
| ret[0] += r[0] |
| ret[1] += r[1] |
| ret[2] += r[2] |
| return ret |
| |
| |
| def get_family_boards(family, one_random, one_first, examples=None, build_system='cmake', |
| extra_defines=(), ci=None): |
| """Get list of boards for a family. |
| |
| Args: |
| family: Family name |
| one_random: If True, return only one random board |
| one_first: If True, return only the first board (alphabetical) |
| examples: PR example filter (-e). The one-board pick then prefers a board that |
| can build at least one of them: the family is in the matrix BECAUSE some |
| board of it builds these examples (ci_select._prune_buildable asks about |
| every board, since CircleCI builds every board), but GHA builds one. Without |
| this, lpc54 selected for host/msc_file_explorer picks lpcxpresso54114 - |
| which every one of those examples skips - and the leg runs to green having |
| compiled nothing and uploaded no metrics. |
| build_system: which skip answer to ask for; the two differ (build_utils) |
| extra_defines: this build's -D tokens, so a board whose only.txt match comes |
| from -DMAX3421_HOST=1 is not judged unbuildable here and buildable in |
| cmake_board |
| ci: force the ci_skip_boards / ci_preferred_boards lists on or off. Default |
| None reads the environment, which is right for a build but NOT for a caller |
| asking what CI would do: ci_select must answer the same on a laptop as on a |
| runner, or /pre-pr and the code-size skill report a family list CI will not |
| reproduce. |
| |
| Returns: |
| List of board names |
| """ |
| if ci is None: |
| ci = bool(os.getenv('GITHUB_ACTIONS') or os.getenv('CIRCLECI')) |
| skip_list = [] |
| preferred_list = [] |
| if ci: |
| skip_list = ci_skip_boards.get(family, []) |
| preferred_list = ci_preferred_boards.get(family, []) |
| |
| all_boards = [] |
| for entry in os.scandir(f"hw/bsp/{family}/boards"): |
| if entry.is_dir() and entry.name not in skip_list: |
| all_boards.append(entry.name) |
| if not all_boards: |
| print(f"No boards found for family '{family}'") |
| return [] |
| all_boards.sort() |
| |
| # If only-one flags are set, honor select list first, then pick first or random |
| if one_first or one_random: |
| def buildable(board): |
| # no filter, or nothing in the filter is buildable anywhere: keep today's |
| # answer rather than inventing a different board |
| return examples is None or any( |
| not build_utils.skip_example(e, board, extra_defines, build_system) |
| for e in examples) |
| |
| # the WHOLE preferred list, in order - stopping at entry one would abandon a |
| # curated list for the raw alphabetical order the moment its first board cannot |
| # build the filter, which also moves the board the metrics baseline is keyed on |
| # the whole preferred list, in order. Unreachable-when-unfiltered: with |
| # examples is None, buildable() is True and the loop returns on entry one. |
| for b in preferred_list: |
| if buildable(b): |
| return [b] |
| candidates = [b for b in all_boards if buildable(b)] or all_boards |
| if one_first: |
| return [candidates[0]] |
| if one_random: |
| return [random.choice(candidates)] |
| |
| return all_boards |
| |
| |
| # ----------------------------- |
| # Main |
| # ----------------------------- |
| def main(): |
| global verbose |
| global parallel_jobs |
| |
| parser = argparse.ArgumentParser() |
| parser.add_argument('families', nargs='*', default=[], help='Families to build') |
| parser.add_argument('-b', '--board', action='append', default=[], help='Boards to build') |
| parser.add_argument('-t', '--toolchain', default='gcc', help='Toolchain to use, default is gcc') |
| parser.add_argument('-s', '--build-system', default='cmake', help='Build system to use, default is cmake') |
| parser.add_argument('-D', '--define-symbol', action='append', default=[], help='Define to pass to build system') |
| parser.add_argument('--build-name', default=None, |
| help='Override build dir name (cmake-build-<name>); default is the board name. Used for HIL variants.') |
| parser.add_argument('--cflag', action='append', default=[], |
| help='Raw compiler flag appended to CFLAGS_CLI, e.g. --cflag=-DCFG_TUD_DWC2_DMA_ENABLE=1 (repeatable)') |
| parser.add_argument('--one-random', action='store_true', default=False, |
| help='Build only one random board of each specified family') |
| parser.add_argument('--one-first', action='store_true', default=False, |
| help='Build only the first board (alphabetical) of each specified family') |
| parser.add_argument('-j', '--jobs', type=int, default=os.cpu_count(), help='Number of jobs to run in parallel') |
| parser.add_argument('-T', '--target', action='append', default=[], |
| help='Build target to use, may be specified multiple times (default: all)') |
| parser.add_argument('-e', '--example', action='append', default=[], |
| help='Only build these examples (role/name, repeatable). Default: all examples') |
| parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output') |
| args = parser.parse_args() |
| |
| families = args.families |
| boards = args.board |
| toolchain = args.toolchain |
| build_system = args.build_system |
| build_defines = args.define_symbol |
| build_name = args.build_name |
| build_cflags = args.cflag |
| one_random = args.one_random |
| one_first = args.one_first |
| build_targets = args.target if args.target else ['all'] |
| examples = args.example or None |
| verbose = args.verbose |
| parallel_jobs = args.jobs |
| |
| for e in args.example: |
| if not EXAMPLE_RE.fullmatch(e): |
| parser.error(f"-e/--example takes 'role/name' (e.g. device/cdc_msc), got '{e}'") |
| # a name no example dir answers to would silently build nothing on every board |
| # and still exit 0 (every row is a Skipped, and main() returns the FAILED count). |
| # The -e lists are generated - from ci_select's example map and from HIL roster |
| # test names - so a stale one must be loud, not green |
| if not os.path.isdir(os.path.join('examples', e)): |
| parser.error(f"-e/--example '{e}': no such example directory examples/{e}") |
| |
| build_defines.append(f'TOOLCHAIN={toolchain}') |
| |
| if len(families) == 0 and len(boards) == 0: |
| print("Please specify families or board to build") |
| return 1 |
| |
| # --build-name renames the single shared build dir, so building more than one |
| # board with it would clobber/mix artifacts |
| if build_name and (len(families) > 0 or len(boards) != 1): |
| print("--build-name requires exactly one board (-b) and no families") |
| return 1 |
| |
| print(build_separator) |
| print(build_format.format('Board', 'Target', '\033[39mResult\033[0m', 'Time')) |
| total_time = time.monotonic() |
| |
| # get all families |
| all_families = [] |
| if 'all' in families: |
| for entry in os.scandir("hw/bsp"): |
| if entry.is_dir() and entry.name != 'espressif' and os.path.isfile(entry.path + "/family.cmake"): |
| all_families.append(entry.name) |
| else: |
| all_families = list(families) |
| all_families.sort() |
| |
| # get boards from families and append to boards list |
| all_boards = list(boards) |
| for f in all_families: |
| all_boards.extend(get_family_boards(f, one_random, one_first, examples, |
| build_system, tuple(build_defines))) |
| |
| # build all boards |
| result = build_boards_list(all_boards, build_defines, build_system, build_name, build_cflags, build_targets, |
| examples) |
| |
| total_time = time.monotonic() - total_time |
| print(build_separator) |
| print(f"Build Summary: {result[0]} {STATUS_OK}, {result[1]} {STATUS_FAILED} and took {total_time:.2f}s") |
| print(build_separator) |
| |
| return result[1] |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |