From 662aecfbb3ec636bb236c5e049a34977c2701369 Mon Sep 17 00:00:00 2001 From: kartikey1306 Date: Tue, 1 Sep 2026 16:20:15 +0530 Subject: [PATCH] fix(cli): repair a merge splice that left commands.py unparseable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master does not parse. `ebuild` cannot start and pytest collects nothing: $ python -c "import ebuild.cli.commands" File "ebuild/cli/commands.py", line 855 """ebuild — A unified embedded OS build system.""" ^ SyntaxError: invalid character '—' (U+2014) Line 855 is a normal docstring. The real damage is 300 lines earlier: _board_config() lost its body and its closing quotes to a copy of _resolve_backend_request()'s last line, spliced in during a conflict resolution: def _board_config() -> Optional[Dict[str, Any]]: """The project's own board description, if it ships one. return resolved_backend, backend_config Its docstring never closes, so every subsequent triple-quote toggles the wrong way and the first em-dash the tokenizer reaches outside a string is reported instead -- at line 855, in a function that is fine. _resolve_backend_request itself still has its own return, so the line is a duplicate rather than a move. Restores the body from f5209ac, the last commit where this file parsed. The same resolution also dropped the whole of #89 -- _NO_TESTS_MARKERS, _TEST_COUNT_PATTERNS, _ran_no_tests, _parse_test_counts and both call sites -- leaving its tests behind and `test` back on: log.success("All tests passed.") reached unconditionally on a zero exit. ctest exits 0 when it finds nothing to run, so a project with enable_testing() and no add_test() reported a pass having executed nothing. Restored from the same commit. Collection goes from "cannot import" to 474 passing. The 51 that fail are pre-existing and were invisible while the module would not parse; they are not all one cause and are listed in the PR description. Co-Authored-By: Claude Opus 5 (1M context) --- ebuild/cli/commands.py | 95 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index 6d7ecbea..237af8c5 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -521,7 +521,18 @@ def _report_footprint(cfg: "ProjectConfig", build_path: Path, log: Logger) -> No def _board_config() -> Optional[Dict[str, Any]]: """The project's own board description, if it ships one. - return resolved_backend, backend_config + 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 def _configure_ninja_backend( @@ -2411,7 +2422,10 @@ def test(log: Logger, config_path: str, build_dir: str, log.info(" ".join(argv)) try: - result = subprocess.run(argv, cwd=str(cwd)) + # Captured rather than inherited, because the exit status alone cannot + # distinguish "every test passed" from "there were no tests". The + # output is echoed below so the terminal reads as it did before. + result = subprocess.run(argv, cwd=str(cwd), capture_output=True, text=True) except FileNotFoundError: log.error( f"{name} is not installed or not on PATH, so the tests cannot be " @@ -2419,11 +2433,32 @@ def test(log: Logger, config_path: str, build_dir: str, ) raise SystemExit(1) + output = (result.stdout or "") + (result.stderr or "") + if output: + click.echo(output.rstrip()) + if result.returncode != 0: log.error(f"Tests failed ({name} exited {result.returncode}).") raise SystemExit(result.returncode) - log.success("All tests passed.") + # ctest exits 0 when it finds nothing to run. A CMakeLists with + # enable_testing() and no add_test() produces a CTestTestfile.cmake, so the + # runner is found, ctest prints "No tests were found!!!", exits 0, and the + # only honest reading of that is not "All tests passed". + if _ran_no_tests(name, output): + log.error(f"{name} completed without running a single test.") + log.info(" A pass here would mean nothing; treating it as a failure.") + raise SystemExit(1) + + counts = _parse_test_counts(name, output) + if counts is not None: + passed, failed = counts + log.success(f"All tests passed ({passed} passed, {failed} failed).") + else: + # No recognised summary. Report the verdict without inventing a number + # the runner did not print. + log.success("All tests passed.") + def _run_native_tests( @@ -2486,6 +2521,60 @@ def _run_native_tests( log.success(f"All {len(selected)} test targets passed.") +#: What each runner prints when it completed having executed nothing. ctest's is +#: the one that matters: it pairs the message with a zero exit status. +_NO_TESTS_MARKERS = { + "ctest": ("No tests were found",), + "meson test": ("No tests defined",), + "cargo test": ("running 0 tests",), +} + +#: Each runner's own summary line, anchored to the phrasing it prints so that a +#: format change shows up as "no counts" rather than as a wrong number. +_TEST_COUNT_PATTERNS = { + "ctest": re.compile( + r"tests passed,\s*(?P\d+)\s+tests? failed out of\s*(?P\d+)"), + "meson test": re.compile( + r"^Ok:\s*(?P\d+).*?^Fail:\s*(?P\d+)", re.S | re.M), + "cargo test": re.compile( + r"test result:.*?(?P\d+) passed;\s*(?P\d+) failed"), +} + + +def _ran_no_tests(name: str, output: str) -> bool: + """True when the runner finished having executed nothing. + + Checked two ways because neither is reliable alone: the marker phrase + catches ctest, which prints no summary at all in this case, and the counts + catch a runner that prints a well-formed summary totalling zero. + """ + for marker in _NO_TESTS_MARKERS.get(name, ()): + if marker in output: + return True + counts = _parse_test_counts(name, output) + return counts is not None and counts[0] + counts[1] == 0 + + +def _parse_test_counts(name: str, output: str): + """(passed, failed) from the runner's own summary, or None. + + `make test` has no standard summary format. Rather than invent one, its + counts stay unknown and the exit status carries the verdict. + """ + pattern = _TEST_COUNT_PATTERNS.get(name) + if pattern is None: + return None + match = pattern.search(output) + if not match: + return None + groups = match.groupdict() + failed = int(groups["failed"]) + if groups.get("passed") is not None: + return int(groups["passed"]), failed + # ctest reports failures out of a total; passed is the remainder. + return int(groups["total"]) - failed, failed + + def _resolve_test_runner( source_dir: Path, build_dir: Path,