diff --git a/CHANGELOG.md b/CHANGELOG.md index c37e8083..e4bb6b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index 6a6a3855..7b41376d 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -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. @@ -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 {} @@ -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, @@ -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 {} @@ -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, @@ -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": @@ -278,3 +323,5 @@ def clean( dry_run, check=False, ) + else: + raise _unknown_backend(backend, "clean", CLEAN_BACKENDS) diff --git a/tests/ebuild/test_dispatch.py b/tests/ebuild/test_dispatch.py index 35a57db6..bec8f32c 100644 --- a/tests/ebuild/test_dispatch.py +++ b/tests/ebuild/test_dispatch.py @@ -12,7 +12,7 @@ from ebuild.build.dispatch import ( ALL_BACKENDS, BackendDispatcher, - BackendError, + UnknownBackendError, detect_backend, ) @@ -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