diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index cfc640f1..6a6a3855 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -13,7 +13,7 @@ import subprocess import sys from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set logger = logging.getLogger(__name__) @@ -24,6 +24,25 @@ TIER_3 = {"cargo"} ALL_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild", "ninja"} +SUPPORTED_BACKENDS = TIER_1 | TIER_2 | TIER_3 + + +class BackendError(RuntimeError): + """Raised when the external dispatcher cannot handle a backend.""" + + +def _validate_backend(backend: str, supported: Set[str]) -> None: + """Reject values that the requested dispatcher operation cannot handle.""" + if backend in supported: + return + + if backend in ALL_BACKENDS: + message = f"BackendDispatcher cannot handle backend '{backend}'." + else: + message = f"Unknown build backend '{backend}'." + + supported_names = ", ".join(sorted(supported)) + raise BackendError(f"{message} Supported backends: {supported_names}.") #: Backends this dispatcher actually drives. "ninja" is ebuild's own backend -- #: the CLI invokes NinjaBackend directly and never routes it through here. @@ -141,8 +160,9 @@ def configure( dry_run: If True, log commands instead of executing them. Raises: - UnknownBackendError: If the backend is not one this dispatcher drives. + BackendError: If the backend cannot be configured here. """ + _validate_backend(backend, SUPPORTED_BACKENDS) config = config or {} self.build_dir.mkdir(parents=True, exist_ok=True) @@ -165,9 +185,6 @@ def configure( elif backend in ("make", "kbuild"): pass # No separate configure step - else: - raise _unknown_backend(backend, "configured") - def build( self, backend: str, @@ -183,8 +200,9 @@ def build( dry_run: If True, log commands instead of executing them. Raises: - UnknownBackendError: If the backend is not one this dispatcher drives. + BackendError: If the backend cannot be built here. """ + _validate_backend(backend, SUPPORTED_BACKENDS) config = config or {} if backend == "cmake": @@ -213,9 +231,6 @@ def build( cmd = ["make", "-C", str(self.source_dir)] _run_or_log(cmd, dry_run) - else: - raise _unknown_backend(backend, "built") - def clean( self, backend: str, @@ -229,8 +244,9 @@ def clean( dry_run: If True, log commands instead of executing them. Raises: - UnknownBackendError: If the backend is not one this dispatcher drives. + BackendError: If the backend cannot be cleaned here. """ + _validate_backend(backend, ALL_BACKENDS) if backend == "cmake": _run_or_log( ["cmake", "--build", str(self.build_dir), "--target", "clean"], @@ -262,5 +278,3 @@ def clean( dry_run, check=False, ) - else: - raise _unknown_backend(backend, "cleaned") diff --git a/ebuild/core/config.py b/ebuild/core/config.py index 5e398091..1701a3ca 100644 --- a/ebuild/core/config.py +++ b/ebuild/core/config.py @@ -102,6 +102,7 @@ class ProjectConfig: source_dir: Path = field(default_factory=lambda: Path(".")) backend: str = "auto" backend_config: Dict[str, Any] = field(default_factory=dict) + system_config: Dict[str, Any] = field(default_factory=dict) def get_target(self, name: str) -> Optional[TargetConfig]: for t in self.targets: @@ -245,13 +246,16 @@ def load_config(config_path: str | Path) -> ProjectConfig: if not isinstance(backend_config, dict): raise ConfigError("'backend_config' must be a mapping.") + backend_config = dict(backend_config) - # For system builds, pull from 'system' section - if raw.get("system") and isinstance(raw["system"], dict): - backend_config.update(raw["system"]) - - if backend == "auto": - backend = "system" + # System-image settings are not a compilation backend. Keep them separate + # so normal backend selection can still auto-detect CMake, Ninja, etc. + system_config = raw.get("system", {}) + if system_config is None: + system_config = {} + if not isinstance(system_config, dict): + raise ConfigError("'system' must be a mapping.") + system_config = dict(system_config) # For cmake/make/meson builds, pull defines from config if raw.get("cmake") and isinstance(raw["cmake"], dict): @@ -334,4 +338,5 @@ def load_config(config_path: str | Path) -> ProjectConfig: source_dir=config_path.parent, backend=backend, backend_config=backend_config, - ) \ No newline at end of file + system_config=system_config, + ) diff --git a/tests/ebuild/test_build_cli.py b/tests/ebuild/test_build_cli.py new file mode 100644 index 00000000..0d46cea0 --- /dev/null +++ b/tests/ebuild/test_build_cli.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""CLI regressions for build backend selection.""" + +import yaml +from click.testing import CliRunner + +from ebuild.cli.commands import cli + + +def test_system_only_config_does_not_report_build_success(tmp_path): + config_path = tmp_path / "build.yaml" + config_path.write_text( + yaml.safe_dump( + { + "project": {"name": "system-image"}, + "system": {"hostname": "eos-device", "image_format": "tar"}, + } + ), + encoding="utf-8", + ) + build_dir = tmp_path / "build" + + result = CliRunner().invoke( + cli, + ["build", "--config", str(config_path), "--build-dir", str(build_dir)], + ) + + assert result.exit_code == 1 + assert "Auto-detected backend: ninja" in result.output + assert "BackendDispatcher cannot handle backend 'ninja'" in result.output + assert "Build completed successfully" not in result.output + assert not build_dir.exists() diff --git a/tests/ebuild/test_config_validation.py b/tests/ebuild/test_config_validation.py index 2d338bdb..f7334040 100644 --- a/tests/ebuild/test_config_validation.py +++ b/tests/ebuild/test_config_validation.py @@ -98,67 +98,54 @@ def test_toolchain_mapping_is_parsed(tmp_path): assert config.toolchain.extra_ldflags == ["--specs=nosys.specs"] -@pytest.mark.parametrize("invalid_packages", [ - {"name": "zlib", "version": "1.2.13"}, - "zlib", - 42, -]) -def test_packages_must_be_a_list(tmp_path, invalid_packages): +def test_system_section_does_not_select_system_backend(tmp_path): path = write_config( tmp_path, - {"project": {"name": "demo"}, "packages": invalid_packages}, + { + "project": {"name": "system-app"}, + "targets": [ + {"name": "app", "type": "executable", "sources": ["main.c"]} + ], + "system": { + "hostname": "eos-device", + "image_format": "ext4", + }, + }, ) - with pytest.raises(ConfigError, match="'packages' must be a list"): - load_config(path) - - -@pytest.mark.parametrize("invalid_item", ["zlib", 42, None]) -def test_package_definition_must_be_mapping(tmp_path, invalid_item): - path = write_config( - tmp_path, - {"project": {"name": "demo"}, "packages": [invalid_item]}, - ) + config = load_config(path) - with pytest.raises(ConfigError, match="expected a YAML mapping"): - load_config(path) + assert config.backend == "auto" + assert config.backend_config == {} + assert config.system_config == { + "hostname": "eos-device", + "image_format": "ext4", + } -def test_package_definition_requires_name(tmp_path): +def test_system_section_does_not_override_explicit_backend(tmp_path): path = write_config( tmp_path, { - "project": {"name": "demo"}, - "packages": [{"version": "1.2.13"}], + "project": {"name": "cmake-system-app"}, + "backend": "cmake", + "cmake": {"defines": {"BUILD_TESTS": "ON"}}, + "system": {"hostname": "eos-device"}, }, ) - with pytest.raises(ConfigError, match="must have a 'name'"): - load_config(path) + config = load_config(path) + + assert config.backend == "cmake" + assert config.backend_config == {"defines": {"BUILD_TESTS": "ON"}} + assert config.system_config == {"hostname": "eos-device"} -def test_packages_list_is_parsed(tmp_path): +def test_system_config_must_be_mapping(tmp_path): path = write_config( tmp_path, - { - "project": {"name": "demo"}, - "packages": [ - {"name": "zlib", "version": "1.2.13"}, - {"name": "mbedtls"}, - ], - }, + {"project": {"name": "demo"}, "system": ["invalid"]}, ) - config = load_config(path) - - assert len(config.packages) == 2 - assert config.packages[0].name == "zlib" - assert config.packages[0].version == "1.2.13" - assert config.packages[1].name == "mbedtls" - assert config.packages[1].version is None - - -def test_omitted_packages_is_empty(tmp_path): - path = write_config(tmp_path, {"project": {"name": "demo"}}) - config = load_config(path) - assert config.packages == [] + with pytest.raises(ConfigError, match="'system' must be a mapping"): + load_config(path) diff --git a/tests/ebuild/test_dispatch.py b/tests/ebuild/test_dispatch.py index c6061472..35a57db6 100644 --- a/tests/ebuild/test_dispatch.py +++ b/tests/ebuild/test_dispatch.py @@ -12,6 +12,7 @@ from ebuild.build.dispatch import ( ALL_BACKENDS, BackendDispatcher, + BackendError, detect_backend, ) @@ -65,25 +66,21 @@ def test_cmake_takes_priority_over_makefile(self, tmp_path): class TestUnknownBackend: - """Unknown backends must raise rather than silently skip. - - All three methods raise RuntimeError for this one condition, so a caller - can guard the whole dispatcher with a single ``except RuntimeError``. - """ + """Unknown backends must raise BackendError rather than silently skip.""" def test_configure_unknown_raises(self, tmp_path): d = BackendDispatcher(tmp_path, tmp_path / "build") - with pytest.raises(RuntimeError, match="bazel"): + with pytest.raises(BackendError, match="Unknown build backend 'bazel'"): d.configure("bazel") def test_build_unknown_raises(self, tmp_path): d = BackendDispatcher(tmp_path, tmp_path / "build") - with pytest.raises(RuntimeError, match="gradle"): + with pytest.raises(BackendError, match="Unknown build backend"): d.build("gradle") def test_clean_unknown_raises(self, tmp_path): d = BackendDispatcher(tmp_path, tmp_path / "build") - with pytest.raises(RuntimeError, match="Unknown build backend"): + with pytest.raises(BackendError, match="Unknown build backend"): d.clean("scons") diff --git a/tests/unit/test_backend_dispatch.py b/tests/unit/test_backend_dispatch.py new file mode 100644 index 00000000..bf78aeea --- /dev/null +++ b/tests/unit/test_backend_dispatch.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Regression tests for external build backend dispatch.""" + +import pytest + +from ebuild.build.dispatch import BackendDispatcher, BackendError + + +@pytest.mark.parametrize("operation", ["configure", "build", "clean"]) +def test_unsupported_backend_fails_closed(tmp_path, operation): + dispatcher = BackendDispatcher(tmp_path, tmp_path / "build") + + with pytest.raises( + BackendError, + match="Unknown build backend 'system'", + ): + getattr(dispatcher, operation)("system") + + +def test_tier_one_configure_is_a_supported_noop(tmp_path): + build_dir = tmp_path / "build" + dispatcher = BackendDispatcher(tmp_path, build_dir) + + dispatcher.configure("make") + + assert build_dir.is_dir()