blob: 3cadb978aed626c6305596afbb6494c3271f2039 [file]
#!/usr/bin/env python3
"""TinyUSB's profile for agentrc's code-size engine (its module docstring is the contract), run by
tools/code_size.py: boards and examples as tools/build.py builds them, ESP-IDF boards through
the exported ESP-IDF or CI's image, the CI-pinned boards and the master snapshots CI stores."""
import contextlib
import glob
import json
import os
import runpy
import shutil
import subprocess
import build_utils
from membrowse_cli import IDF_LD_SCRIPTS, extract_defsyms, extract_ld_scripts, link_command, report_inputs
engine = None # the code-size engine; its main() sets it before any hook runs
NAME = 'TinyUSB'
# resolved like filters(), so a symlinked checkout still matches its filter
ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
OUT_DIR = os.path.join(ROOT, 'cmake-code-size')
BASE_BRANCH = 'master'
DEFAULT_ENGINE = 'membrowse'
BASELINE_REPO = 'hathach/tinyusb'
CI_PINNED_BOARDS = os.path.join(ROOT, '.github', 'ci-pinned-boards.json')
def filters(checkout_dir):
"""The absolute path of `checkout_dir`'s `src/`: collision-free with vendored deps
(pico-sdk, lwip, FreeRTOS, etc.) which live at unrelated paths."""
return [engine.filter_arg(os.path.realpath(os.path.join(checkout_dir, 'src')) + os.sep)]
def unknown_boards(boards):
"""Boards of no family in this checkout: build_utils would skip every example of one,
so a typo must fail instead."""
import build # tools/build.py; its import has no side effects
with contextlib.chdir(ROOT): # build.py reads hw/bsp from the cwd
return [b for b in boards if build.find_family(b) is None]
def missing_examples(examples, sha=None):
"""Examples not named role/name (build.py's -e check), or with no examples/<example>
dir in this checkout nor in commit `sha` when given: one of either tree reaches the
report as one-sided, a typo must fail."""
import build # tools/build.py; its import has no side effects
return [e for e in examples if not build.EXAMPLE_RE.fullmatch(e)
or not os.path.isdir(os.path.join(ROOT, 'examples', e))
and (sha is None or engine.run(['git', '-C', ROOT, 'cat-file', '-e', f'{sha}:examples/{e}']).returncode)]
def check(command, boards, examples, sha, base_local):
errors = []
if unknown := unknown_boards(boards):
errors.append(f'unknown board: {", ".join(unknown)}')
if missing := missing_examples(examples, sha):
errors.append(f'no such example{f" here or in {sha[:10]}" if sha else ""}: {", ".join(missing)}')
if error := esp_without_idf(boards, examples, base_local):
errors.append(error)
return errors
def ci_boards():
"""Boards of .github/ci-pinned-boards.json: CI's membrowse set, which covers every
dcd/hcd driver not waived in its `uncovered` list (drivers-coverage hook)."""
with open(CI_PINNED_BOARDS) as f:
return [entry['board'] for entry in json.load(f)['boards']]
def prepare_base(main_root, worktree_dir):
"""Symlink each dependency the worktree's own tools/get_deps.py lists, when fetched in
the main checkout: the worktree lacks these untracked dirs, and a base revision can
name paths the current manifest has renamed or dropped."""
manifest = runpy.run_path(os.path.join(worktree_dir, 'tools', 'get_deps.py'))
engine.link_deps(main_root, worktree_dir, manifest['deps_all'])
first_error = build_utils.first_error
ESP_IDF_IMAGE = 'espressif/idf:tinyusb' # .github/actions/setup_toolchain/espressif's tag
ESP_IDF_MISSING = (f'ESP-IDF: source $IDF_PATH/export.sh, or docker with {ESP_IDF_IMAGE} '
f'(docker tag the "esp-idf" image of .github/actions/setup_toolchain/toolchain.json as {ESP_IDF_IMAGE}, '
f'as CI does)')
def _link_hops(path):
"""The paths a symlink resolves through, its real path last."""
hops = []
while os.path.islink(path):
path = os.path.normpath(os.path.join(os.path.dirname(path), os.readlink(path)))
hops.append(path)
return hops
def is_espressif(board, src_dir=ROOT):
return os.path.isdir(os.path.join(src_dir, 'hw', 'bsp', 'espressif', 'boards', board))
def _idf_image():
return bool(shutil.which('docker')) and engine.run(['docker', 'image', 'inspect', ESP_IDF_IMAGE]).returncode == 0
def esp_without_idf(boards, examples=None, base_built=False):
"""The error for espressif `boards` no exported ESP-IDF or CI's image can build, else None.
A board builds nothing that skips each of `examples` here, unless `base_built` and one is
absent here, which that base may have."""
import build # tools/build.py; its import has no side effects
def builds(board):
kept, dropped = build.select_examples(board, examples, root=ROOT)
return bool(kept) or base_built and build.NO_SUCH_EXAMPLE in dropped.values()
esp = [b for b in boards if is_espressif(b) and (not examples or builds(b))]
if esp and engine.WINDOWS:
return f'{", ".join(esp)} need ESP-IDF, which code_size.py does not support on Windows'
if esp and not (shutil.which('idf.py') or _idf_image()):
return f'{", ".join(esp)} need {ESP_IDF_MISSING}'
return None
def _idf_command(src_dir, build_dir, name):
"""The argv that runs idf.py on `src_dir` into `build_dir`: an exported ESP-IDF, else CI's
image as this user in a container `name`, with both mounted at their own paths so the
elfs' DWARF matches the filters, and each symlinked dependency's real dir at every path
its link goes through (a base worktree's links point at the checkout's, which may point
at the main checkout's). None when neither is available."""
if shutil.which('idf.py'):
return ['idf.py']
if not _idf_image():
return None
# the image enables ccache; kept here it serves the next run (its default hash_dir keeps
# one tree's DWARF paths out of the other's objects)
cache = os.path.join(OUT_DIR, '_ccache')
os.makedirs(cache, exist_ok=True)
mounts = {p: os.path.realpath(p) for p in map(os.path.abspath, (src_dir, build_dir, cache))}
for dep in runpy.run_path(os.path.join(src_dir, 'tools', 'get_deps.py'))['deps_all']:
path = os.path.join(src_dir, dep)
mounts.update((hop, os.path.realpath(path)) for hop in _link_hops(path))
cmd = ['docker', 'run', '--rm', '--name', name, '--user', f'{os.getuid()}:{os.getgid()}',
'-e', 'HOME=/tmp', '-e', f'CCACHE_DIR={os.path.abspath(cache)}']
for target, source in sorted(mounts.items()):
cmd += ['-v', f'{source}:{target}']
return cmd + [ESP_IDF_IMAGE, 'idf.py']
def _build_idf(src_dir, build_dir, board, example):
"""Build each ESP-IDF example project's app, as tools/build.py does (its bootloader is
not sized); the first failure stops."""
import build # tools/build.py; its import has no side effects
examples = build.select_examples(board, None if example is None else [example], root=src_dir)[0]
if not examples:
return subprocess.CompletedProcess([], 1, '', f'{board} builds no example')
container = f'tinyusb-code-size-{os.getpid()}'
idf = _idf_command(src_dir, build_dir, container)
if idf is None:
return subprocess.CompletedProcess([], 1, '', f'{board} needs {ESP_IDF_MISSING}')
for ex in examples:
ret = None
try:
ret = engine.run(idf + ['-C', os.path.join(src_dir, 'examples', ex), '-B', os.path.join(build_dir, ex),
'-GNinja', f'-DBOARD={board}', 'app'], timeout=600)
finally:
# stopping the docker CLI leaves its container building; --rm only removes an exited one
if idf[0] == 'docker' and (ret is None or ret.returncode == 124):
engine.run(['docker', 'rm', '-f', container])
if ret.returncode != 0:
break
return ret
def build(src_dir, build_dir, board, example):
"""Configure and build examples for a board. When `example` is given, only that target is
built (`ninja -C DIR NAME`), keeping single-example workflows fast. An espressif board
builds each example as its own ESP-IDF project."""
import build # tools/build.py; its import has no side effects
if example:
kept, dropped = build.select_examples(board, [example], root=src_dir)
if not kept:
return (engine.Absent(f'{example} is not in this tree') if dropped[example] == build.NO_SUCH_EXAMPLE
else engine.Skipped(f'{board} does not build {example}'))
if is_espressif(board, src_dir):
ret = _build_idf(src_dir, build_dir, board, example)
else:
name = example and os.path.basename(example)
# build.py always passes its default toolchain
ret = engine.run(build.cmake_configure_cmd(board, build_dir, [f'-DTOOLCHAIN={build.DEFAULT_TOOLCHAIN}'],
os.path.join(src_dir, 'examples')))
# skip.txt/only.txt only mirror the CMake family filter: the configure is the truth
if ret.returncode == 0 and name and build.registered_examples(build_dir, [example]) == []:
return engine.Skipped(f'{board} has no {name} target')
if ret.returncode == 0:
# ninja itself, not `cmake --build`: cmake does not pass a timeout's SIGTERM on
ret = engine.run(['ninja', '-C', build_dir] + ([name] if name else []), timeout=600)
return None if ret.returncode == 0 else ret
def elfs(build_dir, example):
"""<role>/<example>/*.elf: deeper elfs are helpers, e.g. pico-sdk's bs2_default.elf, and
cmake's _deps/ holds fetched tools, e.g. picotool's enc_bootloader.elf."""
# escape the dir, not the wildcards: a checkout path is a path, not a pattern
root = glob.escape(build_dir)
return [e for e in glob.glob(f'{root}/{example or "*/*"}/*.elf')
if not os.path.relpath(e, build_dir).startswith('_deps' + os.sep)]
def link_inputs(elf_path):
"""(the link's build dir, membrowse's inputs: the elf, the linker scripts and defsyms of its
link, its map)."""
build_dir = engine.find_ninja_build_dir(elf_path)
if build_dir is None:
raise RuntimeError(f'no build.ninja found above {elf_path} - cannot '
f'determine its linker scripts')
commands = link_command('ninja', build_dir, elf_path)
# ESP-IDF links its generated scripts by bare name via -L
idf_ld = [os.path.join(build_dir, p) for p in IDF_LD_SCRIPTS]
if any(map(os.path.isfile, idf_ld)):
if not all(map(os.path.isfile, idf_ld)):
raise RuntimeError(f'ESP-IDF build {build_dir} lacks one of {", ".join(idf_ld)}')
ld_scripts = idf_ld
else:
ld_scripts = extract_ld_scripts(commands)
if not ld_scripts:
raise RuntimeError(f'no linker script found in the ninja build graph '
f'for {elf_path}')
# from the link's working dir, as membrowse_cli.report() does
return build_dir, report_inputs(elf_path, ld_scripts, extract_defsyms(commands))
def linkermap_path():
path = os.path.join(ROOT, 'tools', 'linkermap', 'linkermap.py')
if not os.path.isfile(path):
raise FileNotFoundError(f'{path} not found - run `python3 tools/get_deps.py tools/linkermap`')
return path
def baseline_run(repo, sha):
"""The master push Build run whose snapshots are `sha`'s base, as code_size_ci.py picks a PR's."""
ci = engine.load_module('code_size_ci', os.path.join(ROOT, '.github', 'scripts', 'code_size_ci.py'))
return ci.lookup_sha(repo, sha, 'yours')
def snapshot_boards(families, boards, examples, defines=()):
"""The CI-pinned boards a build leg produced, as the Membrowse upload step picks them:
each family's pinned boards that build one of `examples` (build.py's
resolve_ci_boards, boards-only), plus each `-b` board that is pinned. `defines` are
the leg's -D tokens, which can enable examples (MAX3421_HOST=1)."""
import build # tools/build.py; its import has no side effects
pinned = ci_boards()
# build.py skips a -b board that builds none of the examples, before configuring it
picked = [b for b in boards if b in pinned and build.builds_any(b, examples, defines)]
for family in families:
picked += build.resolve_ci_boards(CI_PINNED_BOARDS, family, True, examples, extra_defines=defines)
return list(dict.fromkeys(picked))
skip_example = build_utils.skip_example