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
24 changes: 23 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,21 @@
## [Unreleased]

### Fixed
- **`load_config`:** `toolchain.extra_cflags` and `toolchain.extra_ldflags` must be YAML lists of strings so scalar values cannot silently split into per-character compiler flags.
- **`ebuild.build.dispatch` was unimportable.** Two branches independently added
an unhandled-backend `else` clause to `BackendDispatcher.configure()`; the
merge kept both, leaving a second `else` after the first and a `SyntaxError`
that broke every command importing the module. The duplicate is removed and
the two clauses are consolidated into one
(`ebuild/build/dispatch.py`).
- **`configure(backend="ninja")` no longer silently succeeds.** `ebuild build`
routes `backend: ninja` with no `targets` into the dispatcher, which has no
ninja configure step; the no-op let the CLI report success having built
nothing. It now raises with a message naming the missing `targets`
(`ebuild/build/dispatch.py`).
- **Unhandled-backend errors no longer contradict themselves.** The message
listed `ALL_BACKENDS` as supported, which includes `ninja` — the very backend
being rejected. Each step now reports only the backends it handles
(`ebuild/build/dispatch.py`).
- **Ninja backend: header changes now trigger a rebuild.** The generated `cc`
rule declared no depfile, so Ninja only knew about the sources listed in
`build.yaml`. Editing a header left stale object files in place and the build
Expand Down Expand Up @@ -41,6 +55,14 @@
`build.yaml`, as an absolute path, so both sides agree regardless of the
working directory (`ebuild/cli/commands.py`).

### Added
- `ebuild.build.dispatch.UnknownBackendError`, raised for a backend a dispatch
step does not handle. It derives from both `ValueError` and `RuntimeError`
because the clauses it replaces raised one each and callers depend on both —
notably the CLI's `except RuntimeError`, which turns this into a clean
`exit 1` rather than a traceback. New code should catch
`UnknownBackendError`.

## [3.0.1] - 2026-05-16

### Production Release — Unified EmbeddedOS-org v3.0.1
Expand Down
61 changes: 54 additions & 7 deletions ebuild/build/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,48 @@ def ninja_command():
exe = shutil.which("ninja")
return [exe] if exe else [sys.executable, "-m", "ninja"]

#: Backends ``BackendDispatcher`` accepts, per step. ``ninja`` is absent from
#: configure/build on purpose: ebuild's own Ninja backend is driven directly by
#: the CLI (see ``NinjaBackend``), never through this dispatcher. ``clean``
#: still accepts it because removing a stale ``_build/`` needs no toolchain.
CONFIGURE_BACKENDS = {"cmake", "meson", "cargo", "make", "kbuild"}

BUILD_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild"}

CLEAN_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild", "ninja"}


class UnknownBackendError(ValueError, RuntimeError):
"""Raised when a backend name is not handled by ``BackendDispatcher``.

Inherits from both :class:`ValueError` and :class:`RuntimeError` because
the two behaviours this consolidates were introduced independently and
both are depended on: callers (and the CLI's ``except RuntimeError``
handler, which turns this into a clean ``exit 1`` instead of a traceback)
may catch either. New code should catch ``UnknownBackendError``.
"""


def _unknown_backend(backend: str, action: str, supported: set) -> UnknownBackendError:
"""Build the error raised for a backend a step cannot handle.

Args:
backend: The rejected backend name.
action: Verb phrase naming the step, e.g. ``"configure"``.
supported: Backend names the step does accept.
"""
message = (
f"Unknown build backend '{backend}'. "
f"BackendDispatcher can {action}: {', '.join(sorted(supported))}."
)
if backend == "ninja":
message += (
" ebuild's own ninja backend is invoked directly rather than "
"through BackendDispatcher, and requires 'targets' in build.yaml "
"-- add targets or choose another backend."
)
return UnknownBackendError(message)


def detect_backend(source_dir: Path) -> str:
"""Auto-detect the build system from project files.
Expand Down Expand Up @@ -160,7 +202,7 @@ def configure(
dry_run: If True, log commands instead of executing them.

Raises:
BackendError: If the backend cannot be configured here.
UnknownBackendError: If this step does not handle the backend.
"""
_validate_backend(backend, SUPPORTED_BACKENDS)
config = config or {}
Expand All @@ -179,11 +221,11 @@ def configure(
cmd = ["meson", "setup", str(self.build_dir), str(self.source_dir)]
_run_or_log(cmd, dry_run)

elif backend == "cargo":
pass # Cargo does not have a separate configure step
elif backend in ("cargo", "make", "kbuild"):
pass # These backends have no separate configure step.

elif backend in ("make", "kbuild"):
pass # No separate configure step
else:
raise _unknown_backend(backend, "configure", CONFIGURE_BACKENDS)

def build(
self,
Expand All @@ -200,7 +242,7 @@ def build(
dry_run: If True, log commands instead of executing them.

Raises:
BackendError: If the backend cannot be built here.
UnknownBackendError: If this step does not handle the backend.
"""
_validate_backend(backend, SUPPORTED_BACKENDS)
config = config or {}
Expand Down Expand Up @@ -231,6 +273,9 @@ def build(
cmd = ["make", "-C", str(self.source_dir)]
_run_or_log(cmd, dry_run)

else:
raise _unknown_backend(backend, "build", BUILD_BACKENDS)

def clean(
self,
backend: str,
Expand All @@ -244,7 +289,7 @@ def clean(
dry_run: If True, log commands instead of executing them.

Raises:
BackendError: If the backend cannot be cleaned here.
UnknownBackendError: If this step does not handle the backend.
"""
_validate_backend(backend, ALL_BACKENDS)
if backend == "cmake":
Expand Down Expand Up @@ -278,3 +323,5 @@ def clean(
dry_run,
check=False,
)
else:
raise _unknown_backend(backend, "clean", CLEAN_BACKENDS)
67 changes: 66 additions & 1 deletion tests/ebuild/test_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from ebuild.build.dispatch import (
ALL_BACKENDS,
BackendDispatcher,
BackendError,
UnknownBackendError,
detect_backend,
)

Expand Down Expand Up @@ -166,3 +166,68 @@ def test_clean_kbuild(self, mock_sub, tmp_path):
mock_sub.run.assert_called_once()
cmd = mock_sub.run.call_args[0][0]
assert "clean" in cmd


# ── BackendDispatcher — unknown-backend error contract ──────


class TestUnknownBackendError:
"""The error raised for an unhandled backend.

``configure()`` and ``build()`` grew two independent unhandled-backend
branches on separate branches; merging them left duplicated ``else``
clauses (a SyntaxError that made the module unimportable) raising two
different types. ``UnknownBackendError`` is the single type, derived
from both so neither caller contract broke.
"""

def test_error_is_both_value_and_runtime_error(self, tmp_path):
"""Callers catching either legacy type must keep working.

``ebuild build`` funnels this through ``except RuntimeError``; the
older dispatcher tests catch ``ValueError``.
"""
assert issubclass(UnknownBackendError, ValueError)
assert issubclass(UnknownBackendError, RuntimeError)

d = BackendDispatcher(tmp_path, tmp_path / "build")
with pytest.raises(UnknownBackendError):
d.build("gradle")

def test_configure_ninja_raises_instead_of_silently_passing(self, tmp_path):
"""``backend: ninja`` with no targets reaches the dispatcher.

A silent no-op here let the CLI report "Build completed
successfully" with exit code 0 having built nothing.
"""
d = BackendDispatcher(tmp_path, tmp_path / "build")
with pytest.raises(UnknownBackendError, match="ninja"):
d.configure("ninja")

def test_ninja_error_explains_the_targets_requirement(self, tmp_path):
"""The message must be actionable, not just a rejection."""
d = BackendDispatcher(tmp_path, tmp_path / "build")
with pytest.raises(UnknownBackendError, match="targets"):
d.build("ninja")

def test_error_does_not_list_the_rejected_backend_as_supported(self, tmp_path):
"""ALL_BACKENDS contains 'ninja', so listing it here contradicted
the rejection. Each step reports only what it actually handles."""
d = BackendDispatcher(tmp_path, tmp_path / "build")
with pytest.raises(UnknownBackendError) as excinfo:
d.configure("ninja")

supported = str(excinfo.value).split("BackendDispatcher can configure:")[1]
supported = supported.split(".")[0]
assert "ninja" not in supported

def test_clean_still_accepts_ninja(self, tmp_path):
"""clean() genuinely handles ninja -- only configure/build do not."""
d = BackendDispatcher(tmp_path, tmp_path / "build")
d.clean("ninja", dry_run=True) # must not raise

def test_no_configure_step_backends_stay_noops(self, tmp_path):
"""cargo/make/kbuild are accepted-and-skipped, not errors."""
d = BackendDispatcher(tmp_path, tmp_path / "build")
for backend in ("cargo", "make", "kbuild"):
d.configure(backend) # must not raise