Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 27 additions & 31 deletions ebuild/build/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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}.")


def detect_backend(source_dir: Path) -> str:
Expand Down Expand Up @@ -100,8 +119,9 @@ def configure(
dry_run: If True, log commands instead of executing them.

Raises:
ValueError: If the backend is not recognized.
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)

Expand All @@ -121,24 +141,9 @@ def configure(
elif backend == "cargo":
pass # Cargo does not have a separate configure step

elif backend in ("make", "kbuild", "ninja"):
elif backend in ("make", "kbuild"):
pass # No separate configure step

else:
raise ValueError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}"
)

else:
raise RuntimeError(
f"BackendDispatcher cannot configure backend '{backend}'. "
"This dispatcher only handles cmake, meson, and cargo "
"(make/kbuild need no configure step). ebuild's own ninja "
"backend is invoked directly and requires 'targets' in "
"build.yaml -- add targets or choose another backend."
)

def build(
self,
backend: str,
Expand All @@ -154,8 +159,9 @@ def build(
dry_run: If True, log commands instead of executing them.

Raises:
ValueError: If the backend is not recognized.
BackendError: If the backend cannot be built here.
"""
_validate_backend(backend, SUPPORTED_BACKENDS)
config = config or {}

if backend == "cmake":
Expand Down Expand Up @@ -184,12 +190,6 @@ def build(
cmd = ["make", "-C", str(self.source_dir)]
_run_or_log(cmd, dry_run)

else:
raise ValueError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}"
)

def clean(
self,
backend: str,
Expand All @@ -203,8 +203,9 @@ def clean(
dry_run: If True, log commands instead of executing them.

Raises:
ValueError: If the backend is not recognized.
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"],
Expand Down Expand Up @@ -236,8 +237,3 @@ def clean(
dry_run,
check=False,
)
else:
raise ValueError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}"
)
19 changes: 12 additions & 7 deletions ebuild/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,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:
Expand Down Expand Up @@ -221,13 +222,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):
Expand Down Expand Up @@ -303,4 +307,5 @@ def load_config(config_path: str | Path) -> ProjectConfig:
source_dir=config_path.parent,
backend=backend,
backend_config=backend_config,
)
system_config=system_config,
)
34 changes: 34 additions & 0 deletions tests/ebuild/test_build_cli.py
Original file line number Diff line number Diff line change
@@ -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()
53 changes: 53 additions & 0 deletions tests/ebuild/test_config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,56 @@ def test_toolchain_mapping_is_parsed(tmp_path):
assert config.toolchain.sysroot == "/opt/arm-none-eabi"
assert config.toolchain.extra_cflags == ["-mcpu=cortex-m4"]
assert config.toolchain.extra_ldflags == ["--specs=nosys.specs"]


def test_system_section_does_not_select_system_backend(tmp_path):
path = write_config(
tmp_path,
{
"project": {"name": "system-app"},
"targets": [
{"name": "app", "type": "executable", "sources": ["main.c"]}
],
"system": {
"hostname": "eos-device",
"image_format": "ext4",
},
},
)

config = load_config(path)

assert config.backend == "auto"
assert config.backend_config == {}
assert config.system_config == {
"hostname": "eos-device",
"image_format": "ext4",
}


def test_system_section_does_not_override_explicit_backend(tmp_path):
path = write_config(
tmp_path,
{
"project": {"name": "cmake-system-app"},
"backend": "cmake",
"cmake": {"defines": {"BUILD_TESTS": "ON"}},
"system": {"hostname": "eos-device"},
},
)

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_system_config_must_be_mapping(tmp_path):
path = write_config(
tmp_path,
{"project": {"name": "demo"}, "system": ["invalid"]},
)

with pytest.raises(ConfigError, match="'system' must be a mapping"):
load_config(path)
9 changes: 5 additions & 4 deletions tests/ebuild/test_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ebuild.build.dispatch import (
ALL_BACKENDS,
BackendDispatcher,
BackendError,
detect_backend,
)

Expand Down Expand Up @@ -65,21 +66,21 @@ def test_cmake_takes_priority_over_makefile(self, tmp_path):


class TestUnknownBackend:
"""Unknown backends must raise ValueError rather than silently skip."""
"""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(ValueError, match="Unknown build backend '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(ValueError, match="Unknown build backend"):
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(ValueError, match="Unknown build backend"):
with pytest.raises(BackendError, match="Unknown build backend"):
d.clean("scons")


Expand Down
28 changes: 28 additions & 0 deletions tests/unit/test_backend_dispatch.py
Original file line number Diff line number Diff line change
@@ -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()