diff --git a/.gitignore b/.gitignore index 9ad102a4..d8451ed0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,9 @@ __pycache__/ *.egg-info/ *.egg dist/ -build/ +# Anchored: the bare pattern also matched ebuild/build/, the source package, +# so any new module added there was silently untracked. +/build/ .eggs/ *.whl diff --git a/ebuild/build/footprint.py b/ebuild/build/footprint.py new file mode 100644 index 00000000..0414f81f --- /dev/null +++ b/ebuild/build/footprint.py @@ -0,0 +1,228 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Flash and RAM footprint of a built artifact. + +The MLP developer walk ends with a build that says how much of the board it +just used: + + Flash: 384 KB + RAM: 72 KB + Ready to flash. + +A developer who has to run `arm-none-eabi-size` themselves and remember which +columns to add is not being told; they are being left to find out. + +The accounting matches `scripts/measure_footprint.py` in the eos repo, so the +two tools cannot disagree about what a number means: + + text code + read-only data -> flash + data initialised writable data -> flash AND RAM + bss zero-initialised data -> RAM only + + flash = text + data + ram = data + bss + +`data` is charged to both because it is stored in flash and copied to RAM at +startup. Reading `size`'s "dec" column instead understates RAM. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Tuple + +#: Capacity of the reference part for each board family, in bytes. +#: +#: These are the parts `ebuild new --board` scaffolds against. A family spans +#: several densities -- an STM32F407 has 1 MB of flash and an STM32F401 has +#: 256 KB -- so a project that cares states its own numbers under `memory:` in +#: its board YAML, which takes precedence over anything here. +#: +#: Boards that boot from removable or external storage, and Linux-class parts +#: with no fixed budget, are absent on purpose: a made-up ceiling is worse +#: than no ceiling, because a percentage reads as authoritative. +_REFERENCE_CAPACITY: Dict[str, Tuple[int, int]] = { + # board flash ram + "nrf52": (512 * 1024, 64 * 1024), # nRF52832 + "nrf52840": (1024 * 1024, 256 * 1024), # nRF52840 + "stm32f4": (1024 * 1024, 192 * 1024), # STM32F407 + "stm32h7": (2048 * 1024, 1024 * 1024), # STM32H743 + "rp2040": (2048 * 1024, 264 * 1024), # RP2040 + 2 MB QSPI + "esp32": (4096 * 1024, 520 * 1024), # ESP32-WROOM-32, 4 MB module + "tms570": (3072 * 1024, 256 * 1024), # TMS570LS3137 +} + +_SIZE_LINE = re.compile( + r"^\s*(?P\d+)\s+(?P\d+)\s+(?P\d+)\s+\d+\s+[0-9a-fA-F]+\s" +) + + +class FootprintError(RuntimeError): + """Raised when a footprint cannot be measured.""" + + +@dataclass(frozen=True) +class Footprint: + """Section sizes of one artifact, in bytes.""" + + text: int + data: int + bss: int + + @property + def flash(self) -> int: + """Bytes occupied in flash: code, read-only data, and the stored + image of initialised writable data.""" + return self.text + self.data + + @property + def ram(self) -> int: + """Bytes occupied in RAM once started: initialised data copied out of + flash, plus the zero-initialised region.""" + return self.data + self.bss + + +def find_size_tool(toolchain_prefix: Optional[str] = None) -> Optional[str]: + """Locate the `size` binary for a toolchain. + + A cross build must be measured with its own `size`; the host one reads an + ARM ELF's headers but is not guaranteed to across every binutils version, + and silently reporting host numbers for a firmware image is worse than + reporting none. Returns None when nothing suitable is installed. + """ + if toolchain_prefix and toolchain_prefix != "host": + cross = shutil.which(f"{toolchain_prefix}-size") + if cross: + return cross + # No fallback to the host tool: the numbers would be for a different + # target and nothing in the output would say so. + return None + return shutil.which("size") + + +def measure(artifact: Path, size_tool: Optional[str] = None) -> Footprint: + """Section sizes of ``artifact``. + + Raises FootprintError rather than returning zeros, so a build cannot + report "Flash: 0 KB" when the truth is that nothing was measured. + """ + artifact = Path(artifact) + if not artifact.is_file(): + raise FootprintError(f"no artifact to measure at {artifact}") + + tool = size_tool or shutil.which("size") + if not tool: + raise FootprintError( + "no 'size' tool on PATH, so the footprint cannot be measured " + "(install binutils, or the toolchain's binutils for a cross build)" + ) + + try: + proc = subprocess.run([tool, str(artifact)], capture_output=True, + text=True, timeout=60) + except (OSError, subprocess.TimeoutExpired) as exc: + raise FootprintError(f"{tool} failed on {artifact}: {exc}") from exc + + if proc.returncode != 0: + raise FootprintError( + f"{tool} exited {proc.returncode} on {artifact}: " + f"{(proc.stderr or proc.stdout).strip()[:200]}" + ) + + for line in proc.stdout.splitlines(): + m = _SIZE_LINE.match(line) + if m: + return Footprint(text=int(m.group("text")), + data=int(m.group("data")), + bss=int(m.group("bss"))) + + raise FootprintError( + f"could not read a size line from {tool} output for {artifact}" + ) + + +def board_capacity(board: Optional[str], + board_config: Optional[dict] = None + ) -> Tuple[Optional[int], Optional[int]]: + """Flash and RAM capacity for a board, or (None, None) when unknown. + + A project's own board YAML wins over the reference table: `memory.flash_size` + and `memory.ram_size` are what the hardware descriptions in `hardware/board/` + already use. + """ + if board_config: + memory = board_config.get("memory") or {} + flash = _as_bytes(memory.get("flash_size")) + ram = _as_bytes(memory.get("ram_size")) + if flash or ram: + return flash, ram + if board: + found = _REFERENCE_CAPACITY.get(board.lower()) + if found: + return found + return None, None + + +def _as_bytes(value) -> Optional[int]: + """Board YAMLs write sizes as ints or as hex strings like ``0x200000``.""" + if value is None: + return None + if isinstance(value, int): + return value if value > 0 else None + try: + parsed = int(str(value), 0) + except ValueError: + return None + return parsed if parsed > 0 else None + + +def format_size(n: int) -> str: + """Human-readable size, at the granularity a developer reasons in.""" + if n < 1024: + return f"{n} B" + if n < 1024 * 1024: + return f"{n / 1024:.1f} KB" + return f"{n / (1024 * 1024):.2f} MB" + + +def format_report(fp: Footprint, + flash_capacity: Optional[int] = None, + ram_capacity: Optional[int] = None) -> str: + """The build's closing footprint block. + + A percentage is shown only where the capacity is actually known. Printing + one against a guessed ceiling would be the most misleading number in the + whole build. + """ + lines = [] + for label, used, cap in (("Flash", fp.flash, flash_capacity), + ("RAM ", fp.ram, ram_capacity)): + if cap: + pct = 100.0 * used / cap + lines.append(f" {label}: {format_size(used):>10}" + f" of {format_size(cap):>10} ({pct:.1f}%)") + else: + lines.append(f" {label}: {format_size(used):>10}") + return "\n".join(lines) + + +def over_budget(fp: Footprint, + flash_capacity: Optional[int] = None, + ram_capacity: Optional[int] = None) -> Optional[str]: + """A message naming the region that does not fit, or None. + + Being told at the end of a build beats being told by a linker script, and + beats being told by a board that will not boot. + """ + if flash_capacity and fp.flash > flash_capacity: + return (f"flash usage {format_size(fp.flash)} exceeds the board's " + f"{format_size(flash_capacity)}") + if ram_capacity and fp.ram > ram_capacity: + return (f"RAM usage {format_size(fp.ram)} exceeds the board's " + f"{format_size(ram_capacity)}") + return None diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index 3c4fed9e..8696e216 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -9,9 +9,9 @@ from __future__ import annotations -import glob +import glob import os -import re +import re import shutil import subprocess import threading @@ -219,69 +219,69 @@ def _workspace_repo_paths() -> Dict[str, PackagePaths]: for p in root.glob(pattern) if p.is_dir()} ) if include_dirs: - lib_dirs, libraries = _cached_repo_libraries(root) - paths[name] = PackagePaths( - include_dirs=include_dirs, - lib_dirs=lib_dirs, - libraries=libraries, - ) + lib_dirs, libraries = _cached_repo_libraries(root) + paths[name] = PackagePaths( + include_dirs=include_dirs, + lib_dirs=lib_dirs, + libraries=libraries, + ) return paths - - -# Where `ebuild` puts the CMake build tree for a cached repo. Kept inside the -# clone so `ebuild setup` remains the only thing that owns ~/.ebuild/repos. -_REPO_BUILD_DIRNAME = "_ebuild" - - -def _cached_repo_libraries(root: Path) -> Tuple[List[Path], List[str]]: - """Static libraries a cached repo offers to projects that `use` it. - - Headers alone are not enough: a scaffolded project compiles against - and then fails at the link step with undefined references. - The repo is a CMake project with no install() rules, so there is nothing - to point a -L at until it has been built once. Build it on demand and - cache the result; subsequent builds reuse the tree. - - Returns ([], []) when the repo cannot be built here — a missing cmake, a - repo that is not a CMake project — so the developer still gets a link - error naming the symbol rather than a stack trace from ebuild. - """ - if not (root / "CMakeLists.txt").is_file(): - return [], [] - - build_dir = root / _REPO_BUILD_DIRNAME - archives = sorted(build_dir.rglob("*.a")) if build_dir.is_dir() else [] - - if not archives: - if shutil.which("cmake") is None: - return [], [] - try: - subprocess.run( - ["cmake", "-S", str(root), "-B", str(build_dir)], - check=True, capture_output=True, timeout=600, - ) - subprocess.run( - ["cmake", "--build", str(build_dir), "-j", str(os.cpu_count() or 1)], - check=True, capture_output=True, timeout=1800, - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): - return [], [] - archives = sorted(build_dir.rglob("*.a")) - - if not archives: - return [], [] - - # -L one directory per archive location; -l the archive basenames with - # the lib prefix and .a suffix stripped, which is what the linker wants. - lib_dirs = sorted({a.parent for a in archives}) - libraries = [a.stem[3:] for a in archives if a.stem.startswith("lib")] - return lib_dirs, libraries - - -def _detect_libraries(lib_dir: Path, pkg_name: str) -> List[str]: - """Detect installed library names from a lib/ directory.""" - if not lib_dir.exists(): - return [pkg_name] + + +# Where `ebuild` puts the CMake build tree for a cached repo. Kept inside the +# clone so `ebuild setup` remains the only thing that owns ~/.ebuild/repos. +_REPO_BUILD_DIRNAME = "_ebuild" + + +def _cached_repo_libraries(root: Path) -> Tuple[List[Path], List[str]]: + """Static libraries a cached repo offers to projects that `use` it. + + Headers alone are not enough: a scaffolded project compiles against + and then fails at the link step with undefined references. + The repo is a CMake project with no install() rules, so there is nothing + to point a -L at until it has been built once. Build it on demand and + cache the result; subsequent builds reuse the tree. + + Returns ([], []) when the repo cannot be built here — a missing cmake, a + repo that is not a CMake project — so the developer still gets a link + error naming the symbol rather than a stack trace from ebuild. + """ + if not (root / "CMakeLists.txt").is_file(): + return [], [] + + build_dir = root / _REPO_BUILD_DIRNAME + archives = sorted(build_dir.rglob("*.a")) if build_dir.is_dir() else [] + + if not archives: + if shutil.which("cmake") is None: + return [], [] + try: + subprocess.run( + ["cmake", "-S", str(root), "-B", str(build_dir)], + check=True, capture_output=True, timeout=600, + ) + subprocess.run( + ["cmake", "--build", str(build_dir), "-j", str(os.cpu_count() or 1)], + check=True, capture_output=True, timeout=1800, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): + return [], [] + archives = sorted(build_dir.rglob("*.a")) + + if not archives: + return [], [] + + # -L one directory per archive location; -l the archive basenames with + # the lib prefix and .a suffix stripped, which is what the linker wants. + lib_dirs = sorted({a.parent for a in archives}) + libraries = [a.stem[3:] for a in archives if a.stem.startswith("lib")] + return lib_dirs, libraries + + +def _detect_libraries(lib_dir: Path, pkg_name: str) -> List[str]: + """Detect installed library names from a lib/ directory.""" + if not lib_dir.exists(): + return [pkg_name] libs = [] for f in sorted(lib_dir.iterdir()): @@ -294,162 +294,250 @@ def _detect_libraries(lib_dir: Path, pkg_name: str) -> List[str]: lib_name = lib_name[:-3] if lib_name and lib_name not in libs: libs.append(lib_name) - - return libs if libs else [pkg_name] - - -def _resolve_backend_request( - cfg: ProjectConfig, - backend_override: Optional[str], - source_dir: Path, - log: Logger, -) -> Tuple[str, Dict[str, Any]]: - """Resolve the effective backend and backend-specific config.""" - resolved_backend = backend_override or cfg.backend - backend_config = dict(cfg.backend_config) - - if resolved_backend == "auto": - from ebuild.build.dispatch import detect_backend - - resolved_backend = detect_backend(source_dir) - log.info(f"Auto-detected backend: {resolved_backend}") - - return resolved_backend, backend_config - - -def _configure_ninja_backend( - cfg: ProjectConfig, - build_path: Path, - log: Logger, - *, - suggest_build: bool = True, -) -> None: - """Generate native ebuild Ninja files for configure-only workflows. - - The package paths are merged exactly as `ebuild build` merges them. If - they were not, `configure` and `build` would each write a different - build.ninja to the same path, and a developer who ran `configure` and then - invoked ninja directly would build without the cached-repo include and - library paths. - """ - log.step("Resolving toolchain...") - compiler = resolve_toolchain(cfg.toolchain) - - package_paths = {**_workspace_repo_paths(), - **_install_packages(cfg, build_path, log, verbose=log.verbose)} - - log.step(f"Generating build.ninja in {build_path}/...") - ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) - ninja_backend.generate() - - log.success(f"Generated {build_path / 'build.ninja'}") - log.success(f"Generated {build_path / 'compile_commands.json'}") - if suggest_build: - log.info("Run 'ebuild build' to compile.") - - -def _configure_external_backend( - cfg: ProjectConfig, - resolved_backend: str, - backend_config: Dict[str, Any], - build_path: Path, - log: Logger, -) -> None: - """Run configure behavior for dispatcher-backed build systems.""" - from ebuild.build.dispatch import BackendDispatcher - - no_configure_backends = {"cargo", "make", "kbuild"} - - log.step(f"Using {resolved_backend} backend...") - if resolved_backend in no_configure_backends: - log.info(f"No separate configure step for {resolved_backend}.") - return - - dispatcher = BackendDispatcher(cfg.source_dir, build_path) - log.step(f"Configuring ({resolved_backend})...") - dispatcher.configure( - backend=resolved_backend, - config=backend_config, - ) - log.success(f"Configuration completed successfully ({resolved_backend}).") - - -def _format_subprocess_failure(exc: subprocess.CalledProcessError) -> str: - """Format a subprocess failure for user-facing CLI output.""" - cmd = exc.cmd - if isinstance(cmd, (list, tuple)): - cmd_str = " ".join(str(part) for part in cmd) - else: - cmd_str = str(cmd) - - return f"Command failed (exit code {exc.returncode}): {cmd_str}" - - -def _format_missing_tool(exc: FileNotFoundError) -> str: - """Format missing executable/path errors for user-facing CLI output.""" - if exc.filename: - return f"Required tool or file not found: {exc.filename}" - return str(exc) + + return libs if libs else [pkg_name] + + +def _resolve_backend_request( + cfg: ProjectConfig, + backend_override: Optional[str], + source_dir: Path, + log: Logger, +) -> Tuple[str, Dict[str, Any]]: + """Resolve the effective backend and backend-specific config.""" + resolved_backend = backend_override or cfg.backend + backend_config = dict(cfg.backend_config) + + if resolved_backend == "auto": + from ebuild.build.dispatch import detect_backend + + resolved_backend = detect_backend(source_dir) + log.info(f"Auto-detected backend: {resolved_backend}") + + return resolved_backend, backend_config + + +def _selected_board(default: str = "generic") -> str: + """The board this project targets, from its eos.yaml. + + Read rather than passed in: `ebuild build` takes no --board of its own in + the documented walk, so the value has to survive from `ebuild new` or + `ebuild configure`. + """ + path = Path("eos.yaml") + if not path.is_file(): + return default + try: + import yaml + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + return default + return (data.get("system") or {}).get("board") or default + + +def _report_footprint(cfg: "ProjectConfig", build_path: Path, log: Logger) -> None: + """Print how much of the board the build just used. + + The MLP walk ends with a build that says `Flash: 384 KB / RAM: 72 KB`. A + developer who has to run `size` themselves and remember which columns to + add is not being told; they are being left to find out. + + Never fatal. A footprint that cannot be measured -- no binutils, a cross + toolchain whose `size` is not installed -- is a missing convenience, and + failing a successful build over it would be worse than the silence it + replaces. + """ + from ebuild.build.footprint import ( + FootprintError, board_capacity, find_size_tool, format_report, + measure, over_budget, + ) + + binaries = [t for t in cfg.targets if t.target_type == "executable"] + if not binaries: + return + + artifact = build_path / binaries[0].name + if not artifact.is_file(): + return + + prefix = getattr(cfg.toolchain, "target", None) or "host" + tool = find_size_tool(prefix) + if tool is None: + log.debug(f"no size tool for toolchain {prefix!r}; skipping footprint") + return + + try: + fp = measure(artifact, tool) + except FootprintError as exc: + log.debug(f"footprint unavailable: {exc}") + return + + board = _selected_board(default="") + flash_cap, ram_cap = board_capacity(board or None, _board_config()) + log.info("") + for line in format_report(fp, flash_cap, ram_cap).splitlines(): + log.info(line) + + exceeded = over_budget(fp, flash_cap, ram_cap) + if exceeded: + # Not a build failure: the image linked. It will not fit on the board, + # which the developer needs to hear now rather than from a device that + # will not boot. + log.warning(f"{exceeded} -- this image will not fit.") + else: + log.info("Ready to flash.") + + +def _board_config() -> Optional[Dict[str, Any]]: + """The project's own board description, if it ships one. + + A project that states its part's real capacity should not be measured + against the reference part for its family. + """ + path = Path("board.yaml") + if not path.is_file(): + return None + try: + import yaml + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def _configure_ninja_backend( + cfg: ProjectConfig, + build_path: Path, + log: Logger, + *, + suggest_build: bool = True, +) -> None: + """Generate native ebuild Ninja files for configure-only workflows. + + The package paths are merged exactly as `ebuild build` merges them. If + they were not, `configure` and `build` would each write a different + build.ninja to the same path, and a developer who ran `configure` and then + invoked ninja directly would build without the cached-repo include and + library paths. + """ + log.step("Resolving toolchain...") + compiler = resolve_toolchain(cfg.toolchain) + + package_paths = {**_workspace_repo_paths(), + **_install_packages(cfg, build_path, log, verbose=log.verbose)} + + log.step(f"Generating build.ninja in {build_path}/...") + ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) + ninja_backend.generate() + + log.success(f"Generated {build_path / 'build.ninja'}") + log.success(f"Generated {build_path / 'compile_commands.json'}") + if suggest_build: + log.info("Run 'ebuild build' to compile.") + + +def _configure_external_backend( + cfg: ProjectConfig, + resolved_backend: str, + backend_config: Dict[str, Any], + build_path: Path, + log: Logger, +) -> None: + """Run configure behavior for dispatcher-backed build systems.""" + from ebuild.build.dispatch import BackendDispatcher + + no_configure_backends = {"cargo", "make", "kbuild"} + + log.step(f"Using {resolved_backend} backend...") + if resolved_backend in no_configure_backends: + log.info(f"No separate configure step for {resolved_backend}.") + return + + dispatcher = BackendDispatcher(cfg.source_dir, build_path) + log.step(f"Configuring ({resolved_backend})...") + dispatcher.configure( + backend=resolved_backend, + config=backend_config, + ) + log.success(f"Configuration completed successfully ({resolved_backend}).") + + +def _format_subprocess_failure(exc: subprocess.CalledProcessError) -> str: + """Format a subprocess failure for user-facing CLI output.""" + cmd = exc.cmd + if isinstance(cmd, (list, tuple)): + cmd_str = " ".join(str(part) for part in cmd) + else: + cmd_str = str(cmd) + + return f"Command failed (exit code {exc.returncode}): {cmd_str}" + + +def _format_missing_tool(exc: FileNotFoundError) -> str: + """Format missing executable/path errors for user-facing CLI output.""" + if exc.filename: + return f"Required tool or file not found: {exc.filename}" + return str(exc) # ═══════════════════════════════════════════════════════════════ -# Board selection — shared by `configure --board` and `monitor` -# --------------------------------------------------------------------------- - -# The project-local file that records which board this checkout targets. -_EOS_PROJECT_CONFIG = "eos.yaml" - - -def _record_board_selection(board: str, log: Logger) -> None: - """Persist ``--board`` into eos.yaml under ``system.board``. - - The golden path is `configure --board` then a bare `build`, so the choice - has to outlive the configure process. It is written to eos.yaml rather - than build.yaml because the board is a property of the system being - targeted, which is what eos.yaml already describes. - """ - path = Path(_EOS_PROJECT_CONFIG) - if not path.is_file(): - log.error( - f"No {_EOS_PROJECT_CONFIG} here, so there is nothing to record the " - f"board against. Run this from a project directory created by " - f"'ebuild new'." - ) - raise SystemExit(1) - - import yaml - - try: - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except yaml.YAMLError as e: - log.error(f"{_EOS_PROJECT_CONFIG} is not valid YAML: {e}") - raise SystemExit(1) - - system = data.setdefault("system", {}) - previous = system.get("board") - system["board"] = board - path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") - - if previous and previous != board: - log.info(f"Board: {previous} -> {board}") - else: - log.info(f"Board: {board}") - - -def _selected_board(default: str = "generic") -> str: - """The board recorded by ``configure --board``, or ``default``.""" - path = Path(_EOS_PROJECT_CONFIG) - if not path.is_file(): - return default - try: - import yaml - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - except Exception: - return default - return (data.get("system") or {}).get("board") or default - - -# --------------------------------------------------------------------------- +# Board selection — shared by `configure --board` and `monitor` +# --------------------------------------------------------------------------- + +# The project-local file that records which board this checkout targets. +_EOS_PROJECT_CONFIG = "eos.yaml" + + +def _record_board_selection(board: str, log: Logger) -> None: + """Persist ``--board`` into eos.yaml under ``system.board``. + + The golden path is `configure --board` then a bare `build`, so the choice + has to outlive the configure process. It is written to eos.yaml rather + than build.yaml because the board is a property of the system being + targeted, which is what eos.yaml already describes. + """ + path = Path(_EOS_PROJECT_CONFIG) + if not path.is_file(): + log.error( + f"No {_EOS_PROJECT_CONFIG} here, so there is nothing to record the " + f"board against. Run this from a project directory created by " + f"'ebuild new'." + ) + raise SystemExit(1) + + import yaml + + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + log.error(f"{_EOS_PROJECT_CONFIG} is not valid YAML: {e}") + raise SystemExit(1) + + system = data.setdefault("system", {}) + previous = system.get("board") + system["board"] = board + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + if previous and previous != board: + log.info(f"Board: {previous} -> {board}") + else: + log.info(f"Board: {board}") + + +def _selected_board(default: str = "generic") -> str: + """The board recorded by ``configure --board``, or ``default``.""" + path = Path(_EOS_PROJECT_CONFIG) + if not path.is_file(): + return default + try: + import yaml + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + return default + return (data.get("system") or {}).get("board") or default + + +# --------------------------------------------------------------------------- # Pipeline helper — shared by `pipeline` and `build --board` # ═══════════════════════════════════════════════════════════════ @@ -790,13 +878,13 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], cfg = load_config(config_path) log.info(f"Project: {cfg.name} v{cfg.version}") - build_path = Path(build_dir) - resolved_backend, backend_config = _resolve_backend_request( - cfg=cfg, - backend_override=backend, - source_dir=cfg.source_dir, - log=log, - ) + build_path = Path(build_dir) + resolved_backend, backend_config = _resolve_backend_request( + cfg=cfg, + backend_override=backend, + source_dir=cfg.source_dir, + log=log, + ) # Route: external build systems (cmake, make, meson, cargo, kbuild) # go through the dispatcher. ebuild's own ninja backend handles @@ -810,18 +898,18 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], # Tier 2+3: configure first from ebuild.build.dispatch import TIER_1 if resolved_backend not in TIER_1: - log.step(f"Configuring ({resolved_backend})...") - dispatcher.configure( - backend=resolved_backend, - config=backend_config, - ) + log.step(f"Configuring ({resolved_backend})...") + dispatcher.configure( + backend=resolved_backend, + config=backend_config, + ) # Build log.step(f"Building ({resolved_backend})...") - dispatcher.build( - backend=resolved_backend, - config=backend_config, - ) + dispatcher.build( + backend=resolved_backend, + config=backend_config, + ) log.success(f"Build completed successfully ({resolved_backend}).") return @@ -869,16 +957,17 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], raise SystemExit(1) log.success("Build completed successfully.") + _report_footprint(cfg, build_path, log) - except FileNotFoundError as e: - log.error(_format_missing_tool(e)) - raise SystemExit(1) - except subprocess.CalledProcessError as e: - log.error(_format_subprocess_failure(e)) - raise SystemExit(1) - except (ConfigError, RecipeError) as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) + except FileNotFoundError as e: + log.error(_format_missing_tool(e)) + raise SystemExit(1) + except subprocess.CalledProcessError as e: + log.error(_format_subprocess_failure(e)) + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) except CycleError as e: log.error(f"Dependency error: {e}") raise SystemExit(1) @@ -1014,67 +1103,67 @@ def clean(log: Logger, build_dir: str) -> None: type=click.Path(exists=False), help="Path to the build configuration file.", ) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--backend", - default=None, - type=click.Choice(["auto", "cmake", "make", "meson", "cargo", "ninja", "kbuild"]), - help="Force a specific build backend.", -) -@click.option( - "--board", - default=None, - help="Target board name (e.g., stm32f4, nrf52). Recorded in the project " - "config so later `ebuild build` / `flash` / `monitor` use it.", -) -@click.pass_obj -def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[str], - board: Optional[str]) -> None: - """Generate build files without building.""" - log.header("ebuild — Configure") - - try: - if board: - _record_board_selection(board, log) - - log.step("Loading configuration...") - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - - build_path = Path(build_dir) - resolved_backend, backend_config = _resolve_backend_request( - cfg=cfg, - backend_override=backend, - source_dir=cfg.source_dir, - log=log, - ) - - if resolved_backend == "ninja": - _configure_ninja_backend(cfg, build_path, log) - return - - _configure_external_backend( - cfg=cfg, - resolved_backend=resolved_backend, - backend_config=backend_config, - build_path=build_path, - log=log, - ) - - except FileNotFoundError as e: - log.error(_format_missing_tool(e)) - raise SystemExit(1) - except subprocess.CalledProcessError as e: - log.error(_format_subprocess_failure(e)) - raise SystemExit(1) - except (ConfigError, RecipeError) as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--backend", + default=None, + type=click.Choice(["auto", "cmake", "make", "meson", "cargo", "ninja", "kbuild"]), + help="Force a specific build backend.", +) +@click.option( + "--board", + default=None, + help="Target board name (e.g., stm32f4, nrf52). Recorded in the project " + "config so later `ebuild build` / `flash` / `monitor` use it.", +) +@click.pass_obj +def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[str], + board: Optional[str]) -> None: + """Generate build files without building.""" + log.header("ebuild — Configure") + + try: + if board: + _record_board_selection(board, log) + + log.step("Loading configuration...") + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + + build_path = Path(build_dir) + resolved_backend, backend_config = _resolve_backend_request( + cfg=cfg, + backend_override=backend, + source_dir=cfg.source_dir, + log=log, + ) + + if resolved_backend == "ninja": + _configure_ninja_backend(cfg, build_path, log) + return + + _configure_external_backend( + cfg=cfg, + resolved_backend=resolved_backend, + backend_config=backend_config, + build_path=build_path, + log=log, + ) + + except FileNotFoundError as e: + log.error(_format_missing_tool(e)) + raise SystemExit(1) + except subprocess.CalledProcessError as e: + log.error(_format_subprocess_failure(e)) + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) except (CycleError, ResolveError, FetchError, BuildError) as e: log.error(f"Error: {e}") raise SystemExit(1) @@ -1801,11 +1890,11 @@ def new(log: Logger, project_name: str, template_name: str, board_name: str, # Create project directory structure src_dir = project_dir / "src" src_dir.mkdir(parents=True) - # A scaffolded project ships a runnable test so that `ebuild test`, step - # six of the golden path, passes on a fresh checkout instead of reporting - # that the project declares no tests. - tests_dir = project_dir / "tests" - tests_dir.mkdir(parents=True, exist_ok=True) + # A scaffolded project ships a runnable test so that `ebuild test`, step + # six of the golden path, passes on a fresh checkout instead of reporting + # that the project declares no tests. + tests_dir = project_dir / "tests" + tests_dir.mkdir(parents=True, exist_ok=True) # Template variable substitution replacements = { @@ -1821,7 +1910,7 @@ def new(log: Logger, project_name: str, template_name: str, board_name: str, # Copy and process template files file_mapping = { "main.c.template": src_dir / "main.c", - "test_main.c.template": tests_dir / "test_main.c", + "test_main.c.template": tests_dir / "test_main.c", "build.yaml.template": project_dir / "build.yaml", "eos.yaml.template": project_dir / "eos.yaml", "README.md.template": project_dir / "README.md", @@ -2154,257 +2243,35 @@ def generate_board( except Exception as e: log.error(f"Board generation failed: {e}") raise SystemExit(1) - - -@cli.command() -@click.option( - "--config", - "config_path", - default="build.yaml", - type=click.Path(), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--filter", - "name_filter", - default=None, - help="Only run tests whose name contains this substring.", -) -@click.pass_obj -def test(log: Logger, config_path: str, build_dir: str, - name_filter: Optional[str]) -> None: - """Build and run the project's tests. - - Step six of the golden path. Delegates to whichever runner the project - already uses -- ctest for a CMake tree, `cargo test`, `meson test`, or - `make test` -- rather than imposing a test framework on the project. - """ - log.header("ebuild — Test") - - build_path = Path(build_dir) - - try: - log.step("Loading configuration...") - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - except FileNotFoundError: - log.error( - f"No {config_path} here. Run this from a project directory, or " - f"pass --config." - ) - raise SystemExit(1) - except (ConfigError, RecipeError) as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - - native = [t for t in cfg.targets if t.target_type == "test"] - if native: - _run_native_tests(cfg, native, build_path, log, name_filter) - return - - runner = _resolve_test_runner(cfg.source_dir, build_path, name_filter) - if runner is None: - log.error( - "No test runner found for this project.\n" - " ebuild test drives the project's own runner. Add one of:\n" - " - CMake with enable_testing() + add_test() -> ctest\n" - " - a 'test' target in the Makefile -> make test\n" - " - Cargo.toml -> cargo test\n" - " - meson.build -> meson test" - ) - raise SystemExit(1) - - name, argv, cwd = runner - log.step(f"Running tests with {name}...") - log.info(" ".join(argv)) - - try: - result = subprocess.run(argv, cwd=str(cwd)) - except FileNotFoundError: - log.error( - f"{name} is not installed or not on PATH, so the tests cannot be " - f"run here." - ) - raise SystemExit(1) - - if result.returncode != 0: - log.error(f"Tests failed ({name} exited {result.returncode}).") - raise SystemExit(result.returncode) - - log.success("All tests passed.") - - -def _run_native_tests( - cfg: "ProjectConfig", - targets: List[Any], - build_path: Path, - log: Logger, - name_filter: Optional[str], -) -> None: - """Build and run the project's own ``test`` targets. - - A scaffolded project has no CMake tree and no Makefile, so there is no - external runner to delegate to. The test binaries are ordinary ebuild - targets; build them the same way `ebuild build` does, then run each one - and treat a non-zero exit as a failure. - """ - selected = [t for t in targets if not name_filter or name_filter in t.name] - if not selected: - log.error(f"No test target matches --filter {name_filter!r}.") - raise SystemExit(1) - - log.step("Building test targets...") - _configure_ninja_backend(cfg, build_path, log, suggest_build=False) - - from ebuild.build.dispatch import ninja_command - - # Ninja addresses targets by their output path, and `ebuild build` drives - # it with -f from the project root, so the same form is used here. - argv = ( - ninja_command() - + ["-f", str(build_path / "build.ninja")] - + [str(build_path / t.name) for t in selected] - ) - result = subprocess.run(argv) - if result.returncode != 0: - log.error("Test targets failed to build.") - raise SystemExit(result.returncode) - - failures: List[str] = [] - for target in selected: - binary = build_path / target.name - if not binary.is_file(): - log.error(f"{target.name}: built, but no binary at {binary}") - failures.append(target.name) - continue - - log.step(f"Running {target.name}...") - run = subprocess.run([str(binary)]) - if run.returncode == 0: - log.success(f" {target.name}: passed") - else: - log.error(f" {target.name}: exited {run.returncode}") - failures.append(target.name) - - if failures: - log.error(f"{len(failures)} of {len(selected)} test targets failed: " - + ", ".join(failures)) - raise SystemExit(1) - - log.success(f"All {len(selected)} test targets passed.") - - -def _resolve_test_runner( - source_dir: Path, - build_dir: Path, - name_filter: Optional[str], -) -> Optional[Tuple[str, List[str], Path]]: - """Pick the test runner this project already uses. - - Returns ``(display_name, argv, cwd)``, or None when the project declares - no tests. Ordered so that an explicit CMake test registry wins over a - generic `make test` target in the same tree. - """ - if (build_dir / "CTestTestfile.cmake").is_file(): - argv = ["ctest", "--output-on-failure"] - if name_filter: - argv += ["-R", name_filter] - return "ctest", argv, build_dir - - if (source_dir / "Cargo.toml").is_file(): - argv = ["cargo", "test"] - if name_filter: - argv += [name_filter] - return "cargo test", argv, source_dir - - if (build_dir / "meson-info").is_dir(): - argv = ["meson", "test", "-C", str(build_dir)] - if name_filter: - argv += ["--suite", name_filter] - return "meson test", argv, source_dir - - makefile = next( - (source_dir / n for n in ("Makefile", "makefile", "GNUmakefile") - if (source_dir / n).is_file()), - None, - ) - if makefile is not None: - text = makefile.read_text(encoding="utf-8", errors="replace") - if re.search(r"^test\s*:", text, re.MULTILINE): - return "make test", ["make", "-C", str(source_dir), "test"], source_dir - - return None - - -@cli.command() -@click.option("--port", default=None, - help="Serial device (e.g. /dev/ttyUSB0). Auto-detected if omitted.") -@click.option("--baud", default=115200, type=int, help="Baud rate.") -@click.pass_obj -def monitor(log: Logger, port: Optional[str], baud: int) -> None: - """Attach a serial monitor to the target device. - - Step eight of the golden path -- the step that shows a developer their - first firmware run actually produced output. - """ - log.header("ebuild — Monitor") - log.info(f"Board: {_selected_board()}") - - if port is None: - candidates = _serial_ports() - if not candidates: - log.error( - "No serial device found.\n" - " Looked for /dev/ttyUSB*, /dev/ttyACM*, /dev/tty.usb*.\n" - " Connect the board, or name the device with --port." - ) - raise SystemExit(1) - if len(candidates) > 1: - log.error( - "More than one serial device is connected, so ebuild will not " - "guess which one is the board:\n" - + "\n".join(f" {c}" for c in candidates) - + "\n Choose one with --port." - ) - raise SystemExit(1) - port = candidates[0] - log.info(f"Auto-detected {port}") - - log.step(f"Opening {port} at {baud} baud... (Ctrl-C to exit)") - - try: - import serial # type: ignore[import-untyped] - except ImportError: - log.error( - "pyserial is not installed, so the monitor cannot open the port.\n" - " Install it with: pip install pyserial" - ) - raise SystemExit(1) - - try: - with serial.Serial(port, baud, timeout=0.2) as conn: - while True: - chunk = conn.read(4096) - if chunk: - sys.stdout.write(chunk.decode("utf-8", errors="replace")) - sys.stdout.flush() - except KeyboardInterrupt: - log.info("") - log.success("Monitor closed.") - except Exception as e: - log.error(f"Serial error on {port}: {e}") - raise SystemExit(1) - - -def _serial_ports() -> List[str]: - """Serial devices that look like an attached development board.""" - found: List[str] = [] - for pattern in ("/dev/ttyUSB*", "/dev/ttyACM*", "/dev/tty.usb*"): - found.extend(sorted(glob.glob(pattern))) - return found + + +@cli.command() +@click.option("--json", "as_json", is_flag=True, + help="Emit the checks as JSON, for CI.") +@click.pass_obj +def doctor(log: Logger, as_json: bool) -> None: + """Diagnose the build environment in one command. + + Reports what is installed, what is missing, and what each missing piece + would cost. Read-only: it names the fix rather than applying it. + + Exits non-zero only for problems that actually stop a build, so a + host-only machine with no cross toolchain still passes. + """ + from ebuild.system.doctor import exit_code, format_report, run_all + + checks = run_all() + + if as_json: + import json as _json + click.echo(_json.dumps( + [{"name": c.name, "status": c.status, + "detail": c.detail, "fix": c.fix} for c in checks], + indent=2, + )) + raise SystemExit(exit_code(checks)) + + log.header("ebuild — Environment") + for line in format_report(checks).splitlines(): + click.echo(line) + raise SystemExit(exit_code(checks)) diff --git a/ebuild/system/doctor.py b/ebuild/system/doctor.py new file mode 100644 index 00000000..c4059045 --- /dev/null +++ b/ebuild/system/doctor.py @@ -0,0 +1,181 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Environment diagnosis — one command that says why the build will fail. + +The MLP list asks for "one-command environment diagnosis". Without it, a +missing cross toolchain surfaces as a compiler-not-found error partway through +a build, a missing repo cache surfaces as `eos/hal.h: No such file`, and a +missing `size` silently drops the footprint report. Each of those is a +different-looking symptom of the same class of problem, and none of them names +the fix. + +Every check is read-only: this reports on the environment, it does not repair +it. `ebuild setup` fetches the repos; installing a toolchain is the +developer's package manager's job, and guessing which one they use is how a +diagnostic tool starts doing damage. +""" + +from __future__ import annotations + +import platform +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +OK, MISSING, WARN = "ok", "missing", "warn" + +#: Cross toolchains, and the boards that need them. Named so the report can +#: say what a missing one costs rather than just that it is absent. +_CROSS_TOOLCHAINS = { + "arm-none-eabi": ["stm32f4", "stm32h7", "nrf52", "nrf52840", "rp2040", "tms570"], + "aarch64-linux-gnu": ["rpi4", "am64x"], + "xtensa-esp32-elf": ["esp32"], +} + +_VERSION = re.compile(r"(\d+\.\d+(?:\.\d+)?)") + + +@dataclass +class Check: + name: str + status: str + detail: str = "" + fix: str = "" + + @property + def ok(self) -> bool: + return self.status == OK + + +def _version_of(exe: str, *args: str) -> str: + """First version-looking string in the tool's own output, or ''.""" + try: + proc = subprocess.run([exe, *(args or ("--version",))], + capture_output=True, text=True, timeout=15) + except (OSError, subprocess.TimeoutExpired): + return "" + m = _VERSION.search((proc.stdout or "") + (proc.stderr or "")) + return m.group(1) if m else "" + + +def _tool_check(name: str, exe: str, fix: str, required: bool = True, + version_args: tuple = ()) -> Check: + path = shutil.which(exe) + if not path: + return Check(name, MISSING if required else WARN, f"{exe} not on PATH", fix) + version = _version_of(path, *version_args) + return Check(name, OK, f"{version} ({path})" if version else path) + + +def host_checks() -> List[Check]: + """Everything needed to build for the host.""" + return [ + Check("python", OK, + f"{platform.python_version()} ({sys.executable})"), + _tool_check("ninja", "ninja", + "install ninja-build, or `pip install ninja`"), + _tool_check("cmake", "cmake", + "install cmake (only needed for CMake-backed projects)", + required=False), + _tool_check("host compiler", "cc", + "install a C compiler (build-essential, base-devel, Xcode CLT)"), + _tool_check("size", "size", + "install binutils — without it builds work but report no " + "flash/RAM footprint", + required=False), + _tool_check("git", "git", + "install git — `ebuild setup` clones the eos and eboot repos"), + ] + + +def toolchain_checks() -> List[Check]: + """Cross toolchains, reported as optional. + + A developer targeting only the host is not missing anything, so these are + warnings. What the report adds is which boards each one unlocks. + """ + out = [] + for prefix, boards in sorted(_CROSS_TOOLCHAINS.items()): + exe = f"{prefix}-gcc" + path = shutil.which(exe) + targets = ", ".join(boards) + if path: + out.append(Check(prefix, OK, + f"{_version_of(path)} ({path})".strip())) + else: + out.append(Check(prefix, WARN, f"not installed — no {targets} builds", + f"install the {prefix} toolchain to target {targets}")) + return out + + +def repo_checks() -> List[Check]: + """The cached eos and eboot checkouts `uses: [eos]` resolves against.""" + from ebuild.deps import EBUILD_REPOS_DIR + + out = [] + for name in ("eos", "eboot"): + root = Path(EBUILD_REPOS_DIR) / name + if not root.is_dir(): + out.append(Check(f"{name} repo", MISSING, + f"not cloned at {root}", "run `ebuild setup`")) + continue + if not (root / ".git").exists(): + out.append(Check(f"{name} repo", WARN, + f"{root} exists but is not a git checkout", + "remove it and run `ebuild setup`")) + continue + branch = "" + try: + proc = subprocess.run(["git", "-C", str(root), "rev-parse", + "--abbrev-ref", "HEAD"], + capture_output=True, text=True, timeout=15) + branch = proc.stdout.strip() + except (OSError, subprocess.TimeoutExpired): + pass + out.append(Check(f"{name} repo", OK, + f"{root}" + (f" ({branch})" if branch else ""))) + return out + + +def run_all() -> List[Check]: + return host_checks() + toolchain_checks() + repo_checks() + + +def format_report(checks: List[Check]) -> str: + """The report body, one line per check, widest name setting the column.""" + marks = {OK: "OK ", MISSING: "MISS", WARN: "warn"} + width = max((len(c.name) for c in checks), default=0) + lines = [f" {marks[c.status]} {c.name.ljust(width)} {c.detail}".rstrip() + for c in checks] + + problems = [c for c in checks if c.status == MISSING] + advisories = [c for c in checks if c.status == WARN and c.fix] + + lines.append("") + if problems: + lines.append(f"{len(problems)} problem(s) will stop a build:") + for c in problems: + lines.append(f" - {c.name}: {c.fix}") + else: + lines.append("No problems. The host build path is ready.") + + if advisories: + lines.append("") + lines.append("Optional, for other targets:") + for c in advisories: + lines.append(f" - {c.fix}") + return "\n".join(lines) + + +def exit_code(checks: List[Check]) -> int: + """Non-zero only for things that actually stop a build. + + A warning must not fail CI: a host-only machine legitimately has no cross + toolchain, and a doctor that always exits 1 stops being consulted. + """ + return 1 if any(c.status == MISSING for c in checks) else 0 diff --git a/tests/unit/test_doctor.py b/tests/unit/test_doctor.py new file mode 100644 index 00000000..241071f8 --- /dev/null +++ b/tests/unit/test_doctor.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""`ebuild doctor` — environment diagnosis. + +The MLP list asks for "one-command environment diagnosis". Without it, the +same class of problem surfaces three different ways: a missing cross toolchain +as a compiler-not-found partway through a build, a missing repo cache as +`eos/hal.h: No such file`, a missing `size` as a silently absent footprint. +None of those names the fix. + +The behaviour that matters most is the exit code. A doctor that exits 1 on a +host-only machine — which legitimately has no cross toolchain — stops being +consulted. +""" + +import json + +import pytest +from click.testing import CliRunner + +from ebuild.cli.commands import cli +from ebuild.system import doctor as doc +from ebuild.system.doctor import ( + MISSING, + OK, + WARN, + Check, + exit_code, + format_report, + host_checks, + run_all, + toolchain_checks, +) + + +class TestExitCode: + def test_a_clean_environment_passes(self): + assert exit_code([Check("a", OK), Check("b", OK)]) == 0 + + def test_a_missing_cross_toolchain_does_not_fail(self): + """A host-only machine is not broken. If this returns 1, CI on every + such machine goes red and the command gets ignored.""" + assert exit_code([Check("a", OK), Check("arm-none-eabi", WARN)]) == 0 + + def test_something_that_stops_a_build_fails(self): + assert exit_code([Check("ninja", MISSING)]) == 1 + + def test_no_checks_is_not_a_failure(self): + assert exit_code([]) == 0 + + +class TestReport: + def test_every_check_gets_a_line(self): + checks = [Check("alpha", OK, "1.0"), Check("beta", MISSING, "gone", "fix it")] + body = format_report(checks) + assert "alpha" in body and "beta" in body + + def test_a_missing_check_names_its_fix(self): + """Reporting that something is absent without saying what to do is + the part that makes a diagnostic useless.""" + body = format_report([Check("ninja", MISSING, "not on PATH", + "install ninja-build")]) + assert "install ninja-build" in body + + def test_a_clean_run_says_so_plainly(self): + body = format_report([Check("ninja", OK, "1.11")]) + assert "No problems" in body + + def test_warnings_are_listed_apart_from_problems(self): + """A warning is a capability the developer may not want, not a fault.""" + body = format_report([ + Check("ninja", OK, "1.11"), + Check("arm-none-eabi", WARN, "not installed", + "install it to target stm32f4"), + ]) + assert "No problems" in body + assert "Optional, for other targets" in body + assert "stm32f4" in body + + def test_names_are_column_aligned(self): + body = format_report([Check("a", OK, "x"), Check("looooong", OK, "y")]) + first, second = body.splitlines()[:2] + assert first.index("x") == second.index("y") + + +class TestChecks: + def test_host_checks_cover_the_build_path(self): + names = {c.name for c in host_checks()} + assert {"python", "ninja", "host compiler", "git"} <= names + + def test_python_is_always_ok(self): + """It is running the check.""" + python = next(c for c in host_checks() if c.name == "python") + assert python.status == OK + + def test_a_missing_required_tool_is_a_problem(self, monkeypatch): + monkeypatch.setattr(doc.shutil, "which", lambda _n: None) + assert any(c.status == MISSING for c in host_checks()) + + def test_size_absent_is_optional_not_a_problem(self, monkeypatch): + """Without binutils the build still works; it just reports no + footprint.""" + monkeypatch.setattr(doc.shutil, "which", + lambda n: None if n == "size" else f"/usr/bin/{n}") + size = next(c for c in host_checks() if c.name == "size") + assert size.status == WARN + + def test_every_cross_toolchain_names_the_boards_it_unlocks(self, monkeypatch): + monkeypatch.setattr(doc.shutil, "which", lambda _n: None) + for check in toolchain_checks(): + assert check.status == WARN + assert check.fix, f"{check.name} says nothing about what it is for" + + def test_a_present_toolchain_is_ok(self, monkeypatch): + monkeypatch.setattr(doc.shutil, "which", + lambda n: "/opt/bin/" + n) + monkeypatch.setattr(doc, "_version_of", lambda *a, **k: "13.2") + assert all(c.status == OK for c in toolchain_checks()) + + +class TestCommand: + def test_doctor_runs_and_reports(self): + result = CliRunner().invoke(cli, ["doctor"]) + assert result.exit_code in (0, 1) + assert "python" in result.output + + def test_json_output_is_machine_readable(self): + """CI wants the checks, not the formatting.""" + result = CliRunner().invoke(cli, ["doctor", "--json"]) + payload = json.loads(result.output) + assert isinstance(payload, list) + assert {"name", "status", "detail", "fix"} <= set(payload[0]) + + def test_json_and_text_agree_on_the_exit_code(self): + a = CliRunner().invoke(cli, ["doctor"]) + b = CliRunner().invoke(cli, ["doctor", "--json"]) + assert a.exit_code == b.exit_code + + def test_doctor_is_registered(self): + assert "doctor" in cli.commands diff --git a/tests/unit/test_footprint.py b/tests/unit/test_footprint.py new file mode 100644 index 00000000..3ee44ed4 --- /dev/null +++ b/tests/unit/test_footprint.py @@ -0,0 +1,198 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Flash and RAM footprint reporting. + +The MLP developer walk ends with a build that says how much of the board it +used. Nothing produced those numbers, so a developer had to run `size` by hand +and know which columns to add. + +The accounting has to match `scripts/measure_footprint.py` in the eos repo, or +the two tools disagree about what "flash" means: + + flash = text + data + ram = data + bss +""" + +import subprocess +from pathlib import Path + +import pytest + +from ebuild.build.footprint import ( + Footprint, + FootprintError, + board_capacity, + find_size_tool, + format_report, + format_size, + measure, + over_budget, +) + + +class TestAccounting: + """`data` is charged to both regions: it is stored in flash and copied to + RAM at startup. Reading `size`'s "dec" column instead understates RAM.""" + + def test_flash_is_text_plus_data(self): + assert Footprint(text=1000, data=200, bss=500).flash == 1200 + + def test_ram_is_data_plus_bss(self): + assert Footprint(text=1000, data=200, bss=500).ram == 700 + + def test_data_is_counted_in_both(self): + fp = Footprint(text=0, data=64, bss=0) + assert fp.flash == 64 + assert fp.ram == 64 + + def test_a_pure_bss_buffer_costs_ram_but_not_flash(self): + """A zero-initialised array is not stored in the image.""" + fp = Footprint(text=100, data=0, bss=8192) + assert fp.flash == 100 + assert fp.ram == 8192 + + +class TestMeasure: + def test_measures_a_real_binary(self, tmp_path): + src = tmp_path / "m.c" + src.write_text("static char buf[4096];\nint main(void){return buf[0];}\n") + exe = tmp_path / "m" + subprocess.run(["gcc", str(src), "-o", str(exe)], check=True) + + fp = measure(exe) + assert fp.text > 0 + # The 4 KB buffer is zero-initialised, so it lands in bss and shows up + # in RAM without inflating flash. + assert fp.bss >= 4096 + assert fp.ram >= 4096 + + def test_a_missing_artifact_raises_rather_than_reporting_zero(self, tmp_path): + """Reporting "Flash: 0 KB" when nothing was measured is worse than + saying nothing.""" + with pytest.raises(FootprintError, match="no artifact"): + measure(tmp_path / "nope") + + def test_a_missing_size_tool_raises(self, tmp_path, monkeypatch): + exe = tmp_path / "x" + exe.write_bytes(b"\x7fELF") + monkeypatch.setattr("ebuild.build.footprint.shutil.which", lambda _n: None) + with pytest.raises(FootprintError, match="no 'size' tool"): + measure(exe) + + def test_a_file_that_is_not_an_object_raises(self, tmp_path): + junk = tmp_path / "notelf.txt" + junk.write_text("this is not an object file") + with pytest.raises(FootprintError): + measure(junk) + + +class TestSizeToolSelection: + def test_host_build_uses_the_host_tool(self): + assert find_size_tool("host") == find_size_tool(None) + + def test_cross_build_wants_the_toolchain_tool(self, monkeypatch): + seen = {} + + def fake_which(name): + seen["name"] = name + return "/opt/arm/bin/arm-none-eabi-size" + + monkeypatch.setattr("ebuild.build.footprint.shutil.which", fake_which) + assert find_size_tool("arm-none-eabi").endswith("arm-none-eabi-size") + assert seen["name"] == "arm-none-eabi-size" + + def test_cross_build_does_not_fall_back_to_the_host_tool(self, monkeypatch): + """Host `size` on an ARM ELF would report numbers for a different + target, and nothing in the output would say so.""" + monkeypatch.setattr( + "ebuild.build.footprint.shutil.which", + lambda name: None if name.startswith("arm-") else "/usr/bin/size", + ) + assert find_size_tool("arm-none-eabi") is None + + +class TestBoardCapacity: + def test_a_known_family_has_a_reference_part(self): + flash, ram = board_capacity("stm32f4") + assert flash == 1024 * 1024 + assert ram == 192 * 1024 + + def test_lookup_is_case_insensitive(self): + assert board_capacity("STM32F4") == board_capacity("stm32f4") + + def test_a_linux_class_board_has_no_fixed_budget(self): + """A made-up ceiling is worse than none: a percentage reads as + authoritative.""" + assert board_capacity("rpi4") == (None, None) + + def test_an_unknown_board_is_unknown(self): + assert board_capacity("some-new-board") == (None, None) + + def test_project_board_yaml_overrides_the_reference_table(self): + """An STM32F401 has 256 KB of flash where the F407 has 1 MB; a project + that says so should be measured against its own part.""" + cfg = {"memory": {"flash_size": "0x40000", "ram_size": 65536}} + assert board_capacity("stm32f4", cfg) == (262144, 65536) + + def test_hex_and_int_sizes_both_parse(self): + assert board_capacity(None, {"memory": {"flash_size": "0x100"}})[0] == 256 + assert board_capacity(None, {"memory": {"flash_size": 256}})[0] == 256 + + def test_a_zero_or_unparseable_size_is_not_a_capacity(self): + """`flash: 0 (boots from SD card)` appears in the shipped board + descriptions; zero is not a ceiling.""" + assert board_capacity(None, {"memory": {"flash_size": 0}}) == (None, None) + assert board_capacity(None, {"memory": {"flash_size": "lots"}}) == (None, None) + + def test_a_board_config_without_memory_falls_back(self): + assert board_capacity("stm32f4", {"board_name": "x"})[0] == 1024 * 1024 + + +class TestReport: + def test_percentages_appear_only_with_a_known_capacity(self): + fp = Footprint(text=1000, data=100, bss=200) + assert "%" in format_report(fp, 4096, 4096) + assert "%" not in format_report(fp) + + def test_report_names_both_regions(self): + body = format_report(Footprint(text=1, data=1, bss=1)) + assert "Flash" in body and "RAM" in body + + def test_a_known_flash_and_unknown_ram_shows_one_percentage(self): + body = format_report(Footprint(text=1000, data=0, bss=99), 4096, None) + flash_line, ram_line = body.splitlines() + assert "%" in flash_line + assert "%" not in ram_line + + +class TestBudget: + def test_within_budget_is_silent(self): + assert over_budget(Footprint(1000, 100, 200), 1 << 20, 1 << 20) is None + + def test_flash_overflow_is_named(self): + msg = over_budget(Footprint(2 << 20, 0, 0), 1 << 20, 1 << 20) + assert msg and "flash" in msg + + def test_ram_overflow_is_named(self): + msg = over_budget(Footprint(0, 0, 2 << 20), 1 << 20, 1 << 20) + assert msg and "RAM" in msg + + def test_no_capacity_means_no_verdict(self): + """Without a real ceiling there is nothing to be over.""" + assert over_budget(Footprint(1 << 30, 0, 1 << 30)) is None + + def test_exactly_full_is_not_over(self): + assert over_budget(Footprint(1024, 0, 0), 1024, 4096) is None + + +class TestFormatSize: + @pytest.mark.parametrize("n,expected", [ + (0, "0 B"), + (512, "512 B"), + (1024, "1.0 KB"), + (1536, "1.5 KB"), + (1024 * 1024, "1.00 MB"), + ]) + def test_units(self, n, expected): + assert format_size(n) == expected