diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index f3cfc76d..713ce5c3 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -31,6 +31,16 @@ class PackagePaths: "-fno-pie", "-fno-PIE"} +def _shared_flag() -> str: + """The flag that makes the compiler driver emit a shared object. + + macOS links dynamic libraries with -dynamiclib; ELF platforms use + -shared. It lives on the rule rather than on each edge so a + shared_library target cannot be emitted without it. + """ + return "-dynamiclib" if sys.platform == "darwin" else "-shared" + + def escape_ninja_path(path) -> str: """Escape a path for use inside a Ninja build statement. @@ -179,6 +189,13 @@ def _write_ninja(self) -> None: " command = $ar rcs $out $in", " description = AR $out", "", + # A shared_library edge names this rule. Without the rule the + # generated build.ninja referenced an undefined rule and ninja + # refused the whole file. + "rule link_shared", + f" command = $cc {_shared_flag()} $ldflags $in -o $out $libs", + " description = LINK_SHARED $out", + "", ] toolchain_ldflags = self._get_toolchain_ldflags() @@ -220,7 +237,7 @@ def _write_ninja(self) -> None: link_inputs = obj_files + dep_archives out = escape_ninja_path(self.build_dir / target.name) lines.append( - f"build {_ninja_path(out)}: link " f"{' '.join(_ninja_path(x) for x in link_inputs)}" + f"build {out}: link " f"{' '.join(link_inputs)}" ) if ldflags: lines.append(f" ldflags = {' '.join(ldflags)}") @@ -238,7 +255,7 @@ def _write_ninja(self) -> None: out = escape_ninja_path(self.build_dir / f"lib{target.name}{ext}") if target.target_type == "static_library": - lines.append(f"build {_ninja_path(out)}: ar_rule " f"{' '.join(_ninja_path(x) for x in obj_files)}") + lines.append(f"build {out}: ar_rule " f"{' '.join(obj_files)}") else: # Shared libraries need the same -L/-l wiring executables # get, which the rule preamble alone does not supply. The @@ -254,7 +271,7 @@ def _write_ninja(self) -> None: for lib in pkg.libraries: libs.append(f"-l{lib}") - lines.append(f"build {_ninja_path(out)}: link_shared " f"{' '.join(_ninja_path(x) for x in obj_files)}") + lines.append(f"build {out}: link_shared " f"{' '.join(obj_files)}") if ldflags: lines.append(f" ldflags = {' '.join(ldflags)}") if libs: 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, diff --git a/tests/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index e3c02b5d..b8d7fe41 100644 --- a/tests/unit/test_ninja_backend.py +++ b/tests/unit/test_ninja_backend.py @@ -92,15 +92,15 @@ class TestNinjaPathEscaping(unittest.TestCase): """ def test_colons_and_spaces_in_paths_are_escaped(self): - self.assertEqual(_ninja_path(r"C:\build\main.o"), r"C$:\build\main.o") - self.assertEqual(_ninja_path("/tmp/my project/main.o"), "/tmp/my$ project/main.o") + self.assertEqual(escape_ninja_path(r"C:\build\main.o"), r"C$:\build\main.o") + self.assertEqual(escape_ninja_path("/tmp/my project/main.o"), "/tmp/my$ project/main.o") def test_dollar_is_escaped_before_the_escapes_it_introduces(self): - self.assertEqual(_ninja_path("a$b"), "a$$b") - self.assertEqual(_ninja_path("a$b:c"), "a$$b$:c") + self.assertEqual(escape_ninja_path("a$b"), "a$$b") + self.assertEqual(escape_ninja_path("a$b:c"), "a$$b$:c") def test_ordinary_posix_paths_are_unchanged(self): - self.assertEqual(_ninja_path("/tmp/build/obj/app/src/main.o"), + self.assertEqual(escape_ninja_path("/tmp/build/obj/app/src/main.o"), "/tmp/build/obj/app/src/main.o") def test_every_build_statement_has_one_unescaped_colon(self):