diff --git a/CHANGELOG.md b/CHANGELOG.md index 30aba3d..391544b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ `cjson` (v1.7.18), `nanopb` (v0.4.9.1), `lvgl` (v9.2.2), `tinyusb` (v0.18.0), and `unity` (v2.6.1). ### Fixed +- **A missing or unknown cross toolchain no longer crashes `ebuild build`.** + `build`, `configure` and `test` let `ToolchainError` escape, so a project + whose `toolchain.target` compiler was not installed (or was misspelt) ended + in a Python traceback. They now print `Toolchain error: ` and exit 1, + like other configuration errors. - **Package archive downloads can no longer hang or poison the cache.** `PackageFetcher` downloaded with `urlretrieve()`, which has no timeout: one unresponsive mirror left `ebuild build` hanging until the user killed it. diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index 64be4e8..d16b9ee 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -32,7 +32,7 @@ PackagePaths, executable_output_path, ) -from ebuild.build.toolchain import resolve_toolchain +from ebuild.build.toolchain import ToolchainError, resolve_toolchain from ebuild.cli.integration import register_commands as _register_integration_commands from ebuild.cli.logger import Logger from ebuild.core.config import ConfigError, load_config, ProjectConfig @@ -1148,6 +1148,9 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], except (ConfigError, RecipeError) as e: log.error(f"Configuration error: {e}") raise SystemExit(1) + except ToolchainError as e: + log.error(f"Toolchain error: {e}") + raise SystemExit(1) except CycleError as e: log.error(f"Dependency error: {e}") raise SystemExit(1) @@ -1344,6 +1347,9 @@ def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[s except (ConfigError, RecipeError) as e: log.error(f"Configuration error: {e}") raise SystemExit(1) + except ToolchainError as e: + log.error(f"Toolchain error: {e}") + raise SystemExit(1) except (CycleError, ResolveError, FetchError, BuildError) as e: log.error(f"Error: {e}") raise SystemExit(1) @@ -2717,7 +2723,11 @@ def test( source_dir = cfg.source_dir native = [t for t in cfg.targets if t.target_type == "test"] if native: - _run_native_tests(cfg, native, build_path, log, name_filter) + try: + _run_native_tests(cfg, native, build_path, log, name_filter) + except ToolchainError as e: + log.error(f"Toolchain error: {e}") + raise SystemExit(1) return if runner != "auto": diff --git a/tests/unit/test_toolchain_error_cli.py b/tests/unit/test_toolchain_error_cli.py new file mode 100644 index 0000000..5930e81 --- /dev/null +++ b/tests/unit/test_toolchain_error_cli.py @@ -0,0 +1,70 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""A missing or unknown cross toolchain is a user error, not a crash. + +`resolve_toolchain` raises ToolchainError when `toolchain.target` names an +unknown toolchain or one whose compiler is not on PATH (#171). The `build`, +`configure` and `test` commands did not catch it, so the user got a Python +traceback ending in `ebuild.build.toolchain.ToolchainError` instead of the +one-line reason and exit code 1 that every other configuration error gets. +""" + +import os +from unittest import mock + +import pytest +import yaml +from click.testing import CliRunner + +from ebuild.cli.commands import cli + + +def _project(root, target, target_type="executable"): + (root / "main.c").write_text("int main(void) { return 0; }\n", encoding="utf-8") + (root / "build.yaml").write_text( + yaml.safe_dump( + { + "project": {"name": "demo", "version": "0.1.0"}, + "toolchain": {"target": target}, + "targets": [ + {"name": "app", "type": target_type, "sources": ["main.c"]} + ], + } + ), + encoding="utf-8", + ) + + +def _invoke(monkeypatch, root, *args): + monkeypatch.chdir(root) + # An empty PATH guarantees no cross compiler is found on any machine. + with mock.patch.dict(os.environ, {"PATH": str(root / "no-such-bin")}): + return CliRunner().invoke(cli, list(args)) + + +@pytest.mark.parametrize( + "command,target_type", + [("build", "executable"), ("configure", "executable"), ("test", "test")], +) +@pytest.mark.parametrize( + "target,reason", + [ + ("arm-none-eabi", "not found on PATH"), + ("arm-none-eabii", "unknown toolchain target"), + ], +) +def test_toolchain_error_is_reported_not_raised( + tmp_path, monkeypatch, command, target_type, target, reason +): + _project(tmp_path, target, target_type) + result = _invoke(monkeypatch, tmp_path, command) + + # Exit 1 through SystemExit, with no exception escaping the command. + assert result.exception is None or isinstance(result.exception, SystemExit), ( + f"{command} raised {result.exception!r}" + ) + assert result.exit_code == 1 + assert "Traceback" not in result.output + assert "Toolchain error:" in result.output + assert reason in result.output