From 8c4f314147951c4f30a6675c3a75dc2e9f899ecd Mon Sep 17 00:00:00 2001 From: suresh a Date: Tue, 10 Nov 2026 10:08:25 +0530 Subject: [PATCH] refactor(cli): share one YAML-mapping reader for eos.yaml and board.yaml `_selected_board` and `_board_config` each carried their own copy of "read an optional YAML file, tolerate failure". Only the board.yaml copy rejected a document that is not a mapping, so an eos.yaml whose top level (or whose `system:` value) is a list or string raised AttributeError from `_build_summary` after the image had already linked. Both now go through `_read_yaml_mapping`, and `_selected_board` uses the existing `_EOS_PROJECT_CONFIG` constant instead of a second hardcoded "eos.yaml". Also make the `_fake_qemu` test fixture independent of the interpreter path: a `#!{sys.executable}` shebang cannot carry a path containing spaces, so all four TestRunQemu tests failed with ENOENT in such a checkout. The fixture now execs the interpreter from a /bin/sh wrapper. Co-Authored-By: Claude Opus 5.5 --- ebuild/cli/commands.py | 33 ++++++++++++++----------- tests/unit/test_golden_path.py | 15 +++++++++-- tests/unit/test_golden_path_commands.py | 13 ++++++++++ 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index d16b9ee..b8366e1 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -508,15 +508,28 @@ def _selected_board(default: str = "generic") -> str: the documented walk, so the value has to survive from `ebuild new` or `ebuild configure`. """ - path = Path("eos.yaml") + data = _read_yaml_mapping(Path(_EOS_PROJECT_CONFIG)) or {} + system = data.get("system") + board = system.get("board") if isinstance(system, dict) else None + return board or default + + +def _read_yaml_mapping(path: Path) -> Optional[Dict[str, Any]]: + """The top-level mapping in ``path``, or None if there is not one. + + For optional project files read after a build has already succeeded: + a missing, unreadable, malformed or non-mapping file is treated as + absent rather than raised, because callers index into the result and + a traceback there would fail a build that linked. + """ if not path.is_file(): - return default + return None try: import yaml - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) except Exception: - return default - return (data.get("system") or {}).get("board") or default + return None + return data if isinstance(data, dict) else None def _build_summary(cfg: "ProjectConfig", compiler, package_paths, log: Logger) -> None: @@ -622,15 +635,7 @@ def _board_config() -> Optional[Dict[str, Any]]: A project that states its part's real capacity should not be measured against the reference part for its family. """ - path = Path("board.yaml") - if not path.is_file(): - return None - try: - import yaml - data = yaml.safe_load(path.read_text(encoding="utf-8")) - except Exception: - return None - return data if isinstance(data, dict) else None + return _read_yaml_mapping(Path("board.yaml")) def _configure_ninja_backend( diff --git a/tests/unit/test_golden_path.py b/tests/unit/test_golden_path.py index 230777d..ce576d7 100644 --- a/tests/unit/test_golden_path.py +++ b/tests/unit/test_golden_path.py @@ -1,6 +1,7 @@ """ebuild init / sim / platforms: the getting-started guide's commands.""" from __future__ import annotations +import shlex import stat import sys import textwrap @@ -15,9 +16,19 @@ def _fake_qemu(tmp_path: Path, body: str) -> str: - """A stand-in qemu: a Python script that prints like a guest would.""" + """A stand-in qemu: a Python script that prints like a guest would. + + Launched through a /bin/sh wrapper rather than a `#!{sys.executable}` + shebang, because a shebang cannot carry an interpreter path containing + spaces (e.g. a checkout under "My Projects/"), and the exec then fails + with ENOENT before the test exercises anything. + """ + guest = tmp_path / "fake_guest.py" + guest.write_text(textwrap.dedent(body), encoding="utf-8") script = tmp_path / "fake-qemu" - script.write_text(f"#!{sys.executable}\n" + textwrap.dedent(body), encoding="utf-8") + script.write_text( + f'#!/bin/sh\nexec {shlex.quote(sys.executable)} {shlex.quote(str(guest))} "$@"\n', + encoding="utf-8") script.chmod(script.stat().st_mode | stat.S_IEXEC) return str(script) diff --git a/tests/unit/test_golden_path_commands.py b/tests/unit/test_golden_path_commands.py index 951ac26..794ec62 100644 --- a/tests/unit/test_golden_path_commands.py +++ b/tests/unit/test_golden_path_commands.py @@ -76,6 +76,19 @@ def test_board_defaults_when_unset(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) assert _selected_board() == "generic" + @pytest.mark.parametrize("text", [ + "- stm32f4\n", # top level is a list + "system: stm32f4\n", # system is a string, not a mapping + "system: {board: [unclosed\n", # not valid YAML + ]) + def test_a_malformed_eos_yaml_falls_back_instead_of_raising( + self, tmp_path, monkeypatch, text): + """`ebuild build` reads the board back after the image has linked, to + print its summary. A traceback there would fail a successful build.""" + monkeypatch.chdir(tmp_path) + Path("eos.yaml").write_text(text, encoding="utf-8") + assert _selected_board() == "generic" + def test_board_without_a_project_is_an_error(self, tmp_path, monkeypatch): """Refuse rather than write an eos.yaml into whatever directory the developer happened to be standing in."""