4"""App discovery and resolution helper for ra8-firmware."""
11from collections
import defaultdict
12from collections.abc
import Iterator
13from pathlib
import Path
14from typing
import TypedDict
15from unittest.mock
import patch
17REPO_ROOT = Path(__file__).resolve().parents[2]
18MIN_LIST_COLUMN_WIDTH = 40
21class AppRecord(TypedDict):
22 """One discovered firmware application's canonical metadata."""
32class BuildConfig(TypedDict):
33 """One canonical cross-build configuration for a firmware application."""
38 cmake_args: tuple[str, ...]
42DEFAULT_BUILD_VARIANT =
"default"
43EREADER_NS_XIP_VARIANT =
"ns-xip"
46def _parse_desc(dirpath: str | Path) -> str:
47 """Extract DESCRIPTION text from CMakeLists.txt or src/main.c @brief."""
48 app_dir = Path(dirpath)
49 cmake_path = app_dir /
"CMakeLists.txt"
50 if cmake_path.is_file():
51 content = cmake_path.read_text(encoding=
"utf-8", errors=
"ignore")
52 match = re.search(
r'DESCRIPTION\s+"([^"]+)"', content, re.MULTILINE)
54 return match.group(1).strip()
56 main_path = app_dir /
"src" /
"main.c"
57 if main_path.is_file():
58 content = main_path.read_text(encoding=
"utf-8", errors=
"ignore")
59 match = re.search(
r"@brief\s+([^\n*]+)", content)
61 desc = match.group(1).strip()
62 if desc
and desc !=
"Main entry point.":
67def _relative_group(app_dir: Path, root: Path) -> str:
68 """Return the slash-separated parent group below a discovery root."""
69 parent = app_dir.relative_to(root).parent
70 return "" if parent == Path()
else parent.as_posix()
73def _iter_cmake_dirs(root: Path) -> Iterator[Path]:
74 """Yield every directory under ``root`` that holds a ``CMakeLists.txt``.
76 Build output is pruned from the descent. A ``build`` directory holds only
77 CMake's own generated listfiles -- never a discoverable application -- and
78 scripts/builders/all_examples.sh runs a pool of concurrent per-app builds
79 that create and delete directories underneath exactly those trees while
80 discovery is walking them. A plain ``rglob`` therefore raced: ``os.scandir``
81 raised ``FileNotFoundError`` on a directory that had just vanished and
82 aborted discovery for the whole tree, failing one arbitrary app per
83 cross-build run. Pruning removes the race without changing the result set.
85 Directory names are sorted so the walk order is stable across runs.
87 for dirpath, dirnames, filenames
in os.walk(root):
88 dirnames[:] = sorted(name
for name
in dirnames
if name !=
"build")
89 if "CMakeLists.txt" in filenames:
93def _get_example_apps() -> list[AppRecord]:
94 """Discover firmware examples with real CMake and main source files."""
95 apps: list[AppRecord] = []
96 examples_dir = REPO_ROOT /
"examples"
97 if not examples_dir.is_dir():
99 for app_dir
in _iter_cmake_dirs(examples_dir):
100 if not (app_dir /
"src" /
"main.c").is_file():
102 group = _relative_group(app_dir, examples_dir)
103 if group.startswith(
"shared")
or not group:
106 "cmake/toolchain-ra8p1.cmake" if "ra8p1" in group
else "cmake/toolchain-ra8d2.cmake"
110 "name": app_dir.name,
113 "rel_dir": str(app_dir.relative_to(REPO_ROOT)),
114 "desc": _parse_desc(app_dir),
115 "toolchain": toolchain,
121def _get_board_apps() -> list[AppRecord]:
122 """Discover standalone board applications."""
123 apps: list[AppRecord] = []
124 board_dir = REPO_ROOT /
"apps" /
"board" /
"stand_alone"
125 if not board_dir.is_dir():
127 for app_dir
in _iter_cmake_dirs(board_dir):
128 if not (app_dir /
"src" /
"main.c").is_file():
130 name =
"ra8d2-ereader" if app_dir.name ==
"ereader" else app_dir.name
134 "group":
"board/stand_alone",
136 "rel_dir": str(app_dir.relative_to(REPO_ROOT)),
137 "desc": _parse_desc(app_dir)
or "E-reader product firmware",
138 "toolchain":
"cmake/toolchain-ra8d2.cmake",
144def get_apps() -> list[AppRecord]:
145 """Discover all firmware applications in examples/ and apps/board/."""
146 apps = _get_example_apps() + _get_board_apps()
147 apps.sort(key=
lambda app: (app[
"group"], app[
"name"]))
151def app_id(app: AppRecord) -> str:
152 """Return the stable namespaced identifier for one discovered app."""
153 return f
"{app['group'].replace('/', '::')}::{app['name']}"
156def build_configs(app: AppRecord) -> list[BuildConfig]:
157 """Return every supported cross-build configuration for ``app``."""
158 configs: list[BuildConfig] = [
162 "variant": DEFAULT_BUILD_VARIANT,
164 "build_suffix":
"build",
167 if app[
"rel_dir"] ==
"apps/board/stand_alone/ereader":
170 "id": f
"{app_id(app)}@{EREADER_NS_XIP_VARIANT}",
172 "variant": EREADER_NS_XIP_VARIANT,
173 "cmake_args": (
"-DRA8_EREADER_NS_XIP=ON",),
174 "build_suffix":
"build-ns-xip",
180def get_build_configs() -> list[BuildConfig]:
181 """Return the stable canonical cross-build configuration matrix."""
183 (config
for app
in get_apps()
for config
in build_configs(app)),
184 key=
lambda config: config[
"id"],
188def find_build_config(selector: str) -> BuildConfig |
None:
189 """Resolve an app selector with an optional ``@variant`` suffix."""
190 app_selector, separator, variant = selector.rpartition(
"@")
192 app_selector = selector
193 variant = DEFAULT_BUILD_VARIANT
194 app = find_app(app_selector)
197 return next((config
for config
in build_configs(app)
if config[
"variant"] == variant),
None)
200def find_app(name: str) -> AppRecord |
None:
201 """Find an app by unique short name, exact identifier, or board alias.
203 A short name that becomes ambiguous deliberately resolves to nothing. The
204 caller must then use the namespaced identifier; silently choosing the first
205 walk result would build or flash the wrong firmware.
208 query = name.replace(
"/",
"::")
209 exact_ids = [app
for app
in apps
if app_id(app) == query]
210 if len(exact_ids) == 1:
212 if query ==
"ereader":
213 return next((app
for app
in apps
if app[
"name"] ==
"ra8d2-ereader"),
None)
214 short_matches = [app
for app
in apps
if app[
"name"] == query]
215 if len(short_matches) == 1:
216 return short_matches[0]
217 suffix_matches = [app
for app
in apps
if app_id(app).endswith(f
"::{query}")]
218 return suffix_matches[0]
if len(suffix_matches) == 1
else None
221def _filter_apps(apps: list[AppRecord], query: str, group: str |
None) -> list[AppRecord]:
222 """Filter applications by query or group while retaining stable order."""
224 normalized = query.lower().replace(
"/",
"::")
228 if normalized
in app_id(app).lower()
229 or normalized.replace(
"::",
"/")
in app[
"group"].lower()
230 or normalized
in app[
"desc"].lower()
231 or normalized
in app[
"name"].lower()
234 group_slashes = group.lower().replace(
"::",
"/")
238 if group_slashes
in app[
"group"].lower()
239 or group.lower()
in app[
"group"].replace(
"/",
"::").lower()
244def _app_count(count: int) -> str:
245 """Format an application count without hardcoded singular/plural wording."""
246 return f
"{count} {'app' if count == 1 else 'apps'}"
249def _print_category_summary(apps: list[AppRecord]) ->
None:
250 """Print the stable category help summary."""
251 groups: defaultdict[str, int] = defaultdict(int)
253 groups[app[
"group"]] += 1
255 print(f
"FIRMWARE APPS ({len(apps)} discovered)\n")
257 print(
" just search <keyword> Search apps by keyword or name")
258 print(f
" just apps::example::list List all {len(apps)} firmware apps\n")
259 print(
"CATEGORY FILTERS:")
261 (
"just apps::filter::hil",
"Automated HIL hardware tests",
"ek_ra8d2/hw_validated/hil"),
263 "just apps::filter::manual",
264 "Interactive board demos & LCD screens",
265 "ek_ra8d2/hw_validated/manual",
268 "just apps::filter::c6",
269 "ESP32-C6 wireless co-processor demos",
270 "ek_ra8d2/hw_validated/c6",
273 "just apps::filter::stand_alone",
274 "Standalone E-Reader product firmware",
278 "just apps::filter::ra8p1",
279 "RA8P1 Cortex-M85 + NPU foundation apps",
283 "just apps::filter::pending",
284 "Hardware validation pending queue",
285 "ek_ra8d2/hw_pending",
288 "just apps::filter::pending_c6",
289 "ESP32-C6 bring-up pending queue",
290 "ek_ra8d2/hw_pending/c6",
293 "just apps::filter::pending_manual",
294 "Manual validation pending queue",
295 "ek_ra8d2/hw_pending/manual",
298 "just apps::filter::revalidation",
299 "HIL revalidation queue",
300 "ek_ra8d2/hil_needs_revalidation",
302 (
"just apps::filter::unsupported",
"Unsupported legacy apps",
"_unsupported"),
304 for command, description, group
in categories:
305 print(f
" {command:<35} {description} ({_app_count(groups[group])})")
308def cmd_list(args: argparse.Namespace) -> int:
309 """List all applications or a filtered selection."""
311 query = (args.query
or args.filter
or "").strip()
312 if query
and query.lower() ==
"all":
315 apps = _filter_apps(apps, query, args.group)
317 if not args.all
and not query
and not args.group:
318 _print_category_summary(apps)
322 f
"== FIRMWARE APPS ({len(apps)}) -- build: just apps::build <app> | "
323 "flash: just apps::hardware::flash <app> | "
324 "run: just apps::emulator::run <app>\n"
326 formatted = [(app_id(app), app[
"desc"])
for app
in apps]
327 max_len = max((len(identifier)
for identifier, _
in formatted), default=0)
328 column_width = max(max_len + 2, MIN_LIST_COLUMN_WIDTH)
330 f
" {identifier.ljust(column_width)} {description}" for identifier, description
in formatted
332 print(
"\n".join(lines))
336def cmd_dir(args: argparse.Namespace) -> int:
337 """Print the repository-relative directory for one application."""
338 config = find_build_config(args.app)
340 print(f
"Error: app '{args.app}' not found", file=sys.stderr)
342 print(config[
"app"][
"rel_dir"])
346def cmd_toolchain(args: argparse.Namespace) -> int:
347 """Print the CMake toolchain file for one application."""
348 config = find_build_config(args.app)
350 print(f
"Error: app '{args.app}' not found", file=sys.stderr)
352 print(config[
"app"][
"toolchain"])
356def cmd_name(args: argparse.Namespace) -> int:
357 """Print the canonical artifact basename for an app identifier."""
358 config = find_build_config(args.app)
360 print(f
"Error: app '{args.app}' not found or is ambiguous", file=sys.stderr)
362 print(config[
"app"][
"name"])
366def cmd_id(args: argparse.Namespace) -> int:
367 """Print the stable namespaced identifier for an app selector."""
368 config = find_build_config(args.app)
370 print(f
"Error: app '{args.app}' not found or is ambiguous", file=sys.stderr)
376def cmd_build_dir(args: argparse.Namespace) -> int:
377 """Print the repository-relative build directory for one configuration."""
378 config = find_build_config(args.app)
380 print(f
"Error: app configuration '{args.app}' not found", file=sys.stderr)
382 print(Path(config[
"app"][
"rel_dir"]) / config[
"build_suffix"])
386def cmd_cmake_args(args: argparse.Namespace) -> int:
387 """Write one configuration's additional CMake arguments as NUL records."""
388 config = find_build_config(args.app)
390 print(f
"Error: app configuration '{args.app}' not found", file=sys.stderr)
392 for argument
in config[
"cmake_args"]:
393 sys.stdout.buffer.write(argument.encode(
"ascii") + b
"\0")
397def cmd_matrix(args: argparse.Namespace) -> int:
398 """Print every canonical cross-build configuration."""
399 separator = b
"\0" if args.nul
else b
"\n"
400 for config
in get_build_configs():
401 sys.stdout.buffer.write(config[
"id"].encode(
"ascii") + separator)
405def _selftest_write_app(root: Path, relative: str, *, main: bool =
True) ->
None:
406 """Create one minimal discoverable application fixture."""
407 app_dir = root / relative
408 app_dir.mkdir(parents=
True, exist_ok=
True)
409 (app_dir /
"CMakeLists.txt").write_text(
410 'set(RA8_APP_NAME fixture)\nset(DESCRIPTION "Fixture app")\n',
414 src = app_dir /
"src"
416 (src /
"main.c").write_text(
"int main(void) { return 0; }\n", encoding=
"ascii")
419def _selftest_discovery(root: Path, failures: list[str]) ->
None:
420 """Check complete discovery, build variants, and ignored partial trees."""
421 _selftest_write_app(root,
"examples/tier/alpha")
422 _selftest_write_app(root,
"examples/tier/partial", main=
False)
423 _selftest_write_app(root,
"examples/tier/alpha/build/ghost")
424 _selftest_write_app(root,
"apps/board/stand_alone/ereader")
425 identifiers = [app_id(app)
for app
in get_apps()]
426 expected = [
"board::stand_alone::ra8d2-ereader",
"tier::alpha"]
427 if identifiers != expected:
428 failures.append(f
"discovery returned {identifiers!r}, expected {expected!r}")
429 configs = [config[
"id"]
for config
in get_build_configs()]
431 "board::stand_alone::ra8d2-ereader",
432 "board::stand_alone::ra8d2-ereader@ns-xip",
435 if configs != expected_configs:
436 failures.append(f
"configuration matrix returned {configs!r}")
439def _selftest_resolution(root: Path, failures: list[str]) ->
None:
440 """Check unique selectors resolve and ambiguous or invalid ones fail closed."""
441 _selftest_write_app(root,
"examples/other/alpha")
442 if find_app(
"tier::alpha")
is None or find_build_config(
"ereader@ns-xip")
is None:
443 failures.append(
"namespaced or board-alias selector did not resolve")
444 if find_app(
"alpha")
is not None:
445 failures.append(
"ambiguous short selector did not fail closed")
446 if find_build_config(
"tier::alpha@missing")
is not None:
447 failures.append(
"unknown build variant did not fail closed")
448 if find_build_config(
"does-not-exist")
is not None:
449 failures.append(
"unknown application did not fail closed")
452def _selftest_pruned_walk(root: Path, failures: list[str]) ->
None:
453 """Prove transient build directories are pruned before descent."""
456 def scripted_walk(_root: Path) -> Iterator[tuple[str, list[str], list[str]]]:
458 dirnames = [
"build",
"stable"]
459 yield str(root), dirnames, []
460 pruned =
"build" not in dirnames
461 yield str(root /
"stable"), [], [
"CMakeLists.txt"]
463 with patch.object(os,
"walk", scripted_walk):
464 found = list(_iter_cmake_dirs(root))
466 failures.append(
"build output was not pruned before descent")
467 if found != [root /
"stable"]:
468 failures.append(f
"pruned walk returned {found!r}")
471def selftest() -> int:
472 """Exercise discovery and selector contracts in both directions."""
473 failures: list[str] = []
474 with tempfile.TemporaryDirectory(prefix=
"ra8-apps-")
as temp:
476 with patch(f
"{__name__}.REPO_ROOT", root):
477 _selftest_discovery(root, failures)
478 _selftest_resolution(root, failures)
479 _selftest_pruned_walk(root, failures)
481 for failure
in failures:
482 print(f
" [FAIL] {failure}", file=sys.stderr)
484 print(
"ra8_apps.py --selftest: PASS (discovery, variants, pruning, fail-closed selectors)")
489 """Dispatch the app discovery command-line interface."""
490 parser = argparse.ArgumentParser(description=
"ra8 app discovery helper")
491 parser.add_argument(
"--selftest", action=
"store_true")
492 subparsers = parser.add_subparsers(dest=
"command")
494 list_parser = subparsers.add_parser(
"list")
495 list_parser.add_argument(
"query", nargs=
"?", default=
"")
496 list_parser.add_argument(
"--all",
"-a", action=
"store_true")
497 list_parser.add_argument(
"--group",
"-g")
498 list_parser.add_argument(
"--filter",
"-f")
499 list_parser.set_defaults(func=cmd_list)
501 for command, function
in (
503 (
"toolchain", cmd_toolchain),
506 (
"build-dir", cmd_build_dir),
507 (
"cmake-args", cmd_cmake_args),
509 command_parser = subparsers.add_parser(command)
510 command_parser.add_argument(
"app")
511 command_parser.set_defaults(func=function)
513 matrix_parser = subparsers.add_parser(
"matrix")
514 matrix_parser.add_argument(
"--nul", action=
"store_true")
515 matrix_parser.set_defaults(func=cmd_matrix)
517 args = parser.parse_args()
520 if hasattr(args,
"func"):
521 return args.func(args)
526if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.