Skip to content
Merged
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
38 changes: 26 additions & 12 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}.")

#: Backends this dispatcher actually drives. "ninja" is ebuild's own backend --
#: the CLI invokes NinjaBackend directly and never routes it through here.
Expand Down Expand Up @@ -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)

Expand All @@ -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,
Expand All @@ -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":
Expand Down Expand Up @@ -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,
Expand All @@ -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"],
Expand Down Expand Up @@ -262,5 +278,3 @@ def clean(
dry_run,
check=False,
)
else:
raise _unknown_backend(backend, "cleaned")
19 changes: 12 additions & 7 deletions ebuild/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -334,4 +338,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()
77 changes: 32 additions & 45 deletions tests/ebuild/test_config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
13 changes: 5 additions & 8 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,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")


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()