From dc9d0df59a3bc5311c6a9784aad9cf1d8b06e02d Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sat, 29 Aug 2026 04:29:27 -0700 Subject: [PATCH 1/2] feat(build): report flash and RAM footprint after a build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MLP developer walk in the platform design document ends with a build that says how much of the board it used: Flash: 384 KB RAM: 72 KB Ready to flash. Nothing produced those numbers. A developer had to run `size` themselves and remember which columns to add, which is not being told — it is being left to find out. $ ebuild build [ok] Build completed successfully. Flash: 1.9 KB of 1.00 MB (0.2%) RAM : 300.6 KB of 192.0 KB (156.6%) [warn] RAM usage 300.6 KB exceeds the board's 192.0 KB -- this image will not fit. The accounting matches scripts/measure_footprint.py in the eos repo so the two tools cannot disagree about what a number means: 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. Capacity comes from the project's own board.yaml `memory.flash_size` / `ram_size` when it ships one — the convention the descriptions under hardware/board/ already use — and otherwise from a table of the reference part for each board family. Boards that boot from removable storage, and Linux-class parts with no fixed budget, are deliberately absent: a percentage against a guessed ceiling reads as authoritative, so those report absolute sizes only. An over-budget image is a warning, not a build failure. It linked; it will not fit. The developer needs to hear that now rather than from a board that will not boot. A cross build is measured with its own `size` and never falls back to the host one — host `size` on an ARM ELF reports numbers for a different target and nothing in the output would say so. Where no suitable tool exists the report is skipped rather than guessed, and never fails the build. Also fixes .gitignore, found while committing this: line 8 was a bare `build/` from the Python-packaging block, which also matched `ebuild/build/` — the source package holding dispatch.py, ninja_backend.py and toolchain.py. Those four files predate the rule and stayed tracked, but every new module added there was silently ignored, including this one. Anchored to `/build/`, which keeps the setuptools artifact directory ignored and stops the pattern reaching nested source. 235 tests pass, up from 202; 32 are new here. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 +- ebuild/build/footprint.py | 228 +++++++++++++++++++++++++++++++++++ ebuild/cli/commands.py | 89 ++++++++++++++ tests/unit/test_footprint.py | 198 ++++++++++++++++++++++++++++++ 4 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 ebuild/build/footprint.py create mode 100644 tests/unit/test_footprint.py diff --git a/.gitignore b/.gitignore index 64115a9d..135341d4 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 b7b95228..2dd646ff 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -226,6 +226,94 @@ def _resolve_backend_request( 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, @@ -708,6 +796,7 @@ 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)) 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 From c3727e60a1753ddf11f3b2d60211a3741fe5599d Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sat, 29 Aug 2026 04:31:57 -0700 Subject: [PATCH 2/2] =?UTF-8?q?feat(cli):=20add=20`ebuild=20doctor`=20?= =?UTF-8?q?=E2=80=94=20one-command=20environment=20diagnosis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MLP list asks for it, and the absence shows: the same class of problem currently surfaces three different ways. A missing cross toolchain appears as a compiler-not-found partway through a build. A missing repo cache appears as `eos/hal.h: No such file`. A missing binutils silently drops the footprint report. None of the three names the fix. $ ebuild doctor OK python 3.12.14 OK ninja 1.13.2 (/usr/bin/ninja) OK host compiler 15.2.0 (/usr/bin/cc) OK arm-none-eabi 14.2 (/usr/bin/arm-none-eabi-gcc) warn xtensa-esp32-elf not installed — no esp32 builds OK eos repo ~/.ebuild/repos/eos (master) No problems. The host build path is ready. Optional, for other targets: - install the xtensa-esp32-elf toolchain to target esp32 Three decisions worth naming. Read-only. It reports; it does not repair. `ebuild setup` fetches the repos, and installing a toolchain belongs to the developer's package manager — guessing which one they use is how a diagnostic tool starts doing damage. The exit code is non-zero only for things that actually stop a build. A host-only machine legitimately has no cross toolchain, and a doctor that always exits 1 stops being consulted, which costs more than it saves. A missing cross toolchain reports the boards it would have unlocked rather than just its own absence. "arm-none-eabi-gcc not found" is a fact; "no stm32f4, stm32h7, nrf52, rp2040 or tms570 builds" is the consequence the developer is actually deciding about. `--json` emits the same checks for CI, and agrees with the text form on the exit code. 254 tests pass, up from 235; 19 are new here. Co-Authored-By: Claude Opus 5 (1M context) --- ebuild/cli/commands.py | 32 +++++++ ebuild/system/doctor.py | 181 ++++++++++++++++++++++++++++++++++++++ tests/unit/test_doctor.py | 141 +++++++++++++++++++++++++++++ 3 files changed, 354 insertions(+) create mode 100644 ebuild/system/doctor.py create mode 100644 tests/unit/test_doctor.py diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index 2dd646ff..3c87ef17 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -2066,3 +2066,35 @@ def generate_board( except Exception as e: log.error(f"Board generation failed: {e}") raise SystemExit(1) + + +@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