Skip to content
Open
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
34 changes: 21 additions & 13 deletions ebuild/build/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import logging
import subprocess
import shutil
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
Expand Down Expand Up @@ -73,6 +74,19 @@ def _run_or_log(
return subprocess.run(cmd, check=check, cwd=cwd)


def ninja_command() -> list:
"""How to invoke ninja on this machine.

A real ninja on PATH is preferred; `python -m ninja` only works when the
PyPI `ninja` wheel is installed, so hardcoding it made a machine with
ninja properly installed fail with "No module named ninja".
"""
exe = shutil.which("ninja")
if exe:
return [exe]
return [sys.executable, "-m", "ninja"]


class BackendDispatcher:
"""Dispatch configure/build/clean to external build systems.

Expand Down Expand Up @@ -100,7 +114,7 @@ def configure(
dry_run: If True, log commands instead of executing them.

Raises:
ValueError: If the backend is not recognized.
RuntimeError: If the backend is not recognized.
"""
config = config or {}
self.build_dir.mkdir(parents=True, exist_ok=True)
Expand All @@ -121,15 +135,9 @@ def configure(
elif backend == "cargo":
pass # Cargo does not have a separate configure step

elif backend in ("make", "kbuild", "ninja"):
elif backend in ("make", "kbuild"):
pass # No separate configure step

else:
raise ValueError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}"
)

else:
raise RuntimeError(
f"BackendDispatcher cannot configure backend '{backend}'. "
Expand All @@ -154,7 +162,7 @@ def build(
dry_run: If True, log commands instead of executing them.

Raises:
ValueError: If the backend is not recognized.
RuntimeError: If the backend is not recognized.
"""
config = config or {}

Expand Down Expand Up @@ -185,7 +193,7 @@ def build(
_run_or_log(cmd, dry_run)

else:
raise ValueError(
raise RuntimeError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}"
)
Expand All @@ -203,7 +211,7 @@ def clean(
dry_run: If True, log commands instead of executing them.

Raises:
ValueError: If the backend is not recognized.
RuntimeError: If the backend is not recognized.
"""
if backend == "cmake":
_run_or_log(
Expand Down Expand Up @@ -232,12 +240,12 @@ def clean(
)
elif backend == "ninja":
_run_or_log(
[sys.executable, "-m", "ninja", "-C", str(self.build_dir), "-t", "clean"],
ninja_command() + ["-C", str(self.build_dir), "-t", "clean"],
dry_run,
check=False,
)
else:
raise ValueError(
raise RuntimeError(
f"Unknown build backend '{backend}'. "
f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}"
)
39 changes: 33 additions & 6 deletions ebuild/build/ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
Expand All @@ -30,6 +31,14 @@ class PackagePaths:
"-fno-pie", "-fno-PIE"}


def _shared_flag() -> str:
"""The compiler flag that builds a shared object on this platform.

macOS links dynamic libraries with -dynamiclib; ELF platforms use -shared.
"""
return "-dynamiclib" if sys.platform == "darwin" else "-shared"


class NinjaBackend:
"""Generate build.ninja from a ProjectConfig and resolved toolchain.

Expand Down Expand Up @@ -104,6 +113,24 @@ def _resolve_target_cflags(self, target) -> List[str]:

return cflags

def _object_path(self, target, src: str) -> Path:
"""Object file for one source within one target.

Called from two places and defined in neither, so every ninja build
raised AttributeError before this existed.

Namespaced by target name: a source shared by two targets must produce
two distinct objects. Ninja rejects two edges writing the same output,
and the targets may compile it with different cflags.

The source's directory structure is flattened into the filename rather
than mirrored beneath the build directory. Mirroring lets a source from
outside the project -- ``../shared/util.c`` -- place its object outside
the build directory too, where ``clean`` will not find it.
"""
flat = re.sub(r"[^A-Za-z0-9_.-]", "_", str(src).replace("\\", "/"))
return self.build_dir / "obj" / target.name / f"{flat}.o"

def _write_ninja(self) -> None:
"""Write the build.ninja file."""
ninja_path = self.build_dir / "build.ninja"
Expand All @@ -124,7 +151,7 @@ def _write_ninja(self) -> None:
" description = LINK $out",
"",
"rule link_shared",
" command = $cc -shared $ldflags $in -o $out $libs",
f" command = $cc {_shared_flag()} $ldflags $in -o $out $libs",
" description = LINK_SHARED $out",
"",
"rule ar_rule",
Expand Down Expand Up @@ -190,11 +217,11 @@ def _write_ninja(self) -> None:
if target.target_type == "static_library":
lines.append(f"build {out}: ar_rule {' '.join(obj_files)}")
else:
# Shared libraries need the platform's "build a shared
# object" flag and the same -L/-l wiring executables get,
# neither of which the generic `link` rule provides.
# Shared libraries need the same -L/-l wiring executables
# get, which the rule preamble alone does not supply. The
# shared-object flag itself lives in the link_shared rule,
# so it must not be repeated here.
ldflags = list(target.ldflags)
ldflags.insert(0, "-dynamiclib" if sys.platform == "darwin" else "-shared")
libs = []
for pkg_name in target.uses:
pkg = self.package_paths.get(pkg_name)
Expand All @@ -204,7 +231,7 @@ def _write_ninja(self) -> None:
for lib in pkg.libraries:
libs.append(f"-l{lib}")

lines.append(f"build {out}: link {' '.join(obj_files)}")
lines.append(f"build {out}: link_shared {' '.join(obj_files)}")
if ldflags:
lines.append(f" ldflags = {' '.join(ldflags)}")
if libs:
Expand Down
12 changes: 8 additions & 4 deletions tests/ebuild/test_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,25 @@ def test_cmake_takes_priority_over_makefile(self, tmp_path):


class TestUnknownBackend:
"""Unknown backends must raise ValueError rather than silently skip."""
"""Unknown backends must raise rather than silently skip.

All three methods raise RuntimeError for this one condition, so a caller
can guard the whole dispatcher with a single ``except RuntimeError``.
"""

def test_configure_unknown_raises(self, tmp_path):
d = BackendDispatcher(tmp_path, tmp_path / "build")
with pytest.raises(ValueError, match="Unknown build backend 'bazel'"):
with pytest.raises(RuntimeError, match="bazel"):
d.configure("bazel")

def test_build_unknown_raises(self, tmp_path):
d = BackendDispatcher(tmp_path, tmp_path / "build")
with pytest.raises(ValueError, match="Unknown build backend"):
with pytest.raises(RuntimeError, match="Unknown build backend"):
d.build("gradle")

def test_clean_unknown_raises(self, tmp_path):
d = BackendDispatcher(tmp_path, tmp_path / "build")
with pytest.raises(ValueError, match="Unknown build backend"):
with pytest.raises(RuntimeError, match="Unknown build backend"):
d.clean("scons")


Expand Down
18 changes: 14 additions & 4 deletions tests/unit/test_ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ def test_shared_library_gets_shared_flag(self):

shared_flag = "-dynamiclib" if sys.platform == "darwin" else "-shared"
self.assertIn(shared_flag, ninja)
# It must use the `link` rule (compiler driver), not `ar_rule`.
# It must go through a compiler-driver rule, not the `ar` archiver.
# link_shared is that rule, and it carries the shared-object flag, so
# the flag is never repeated in the edge's ldflags.
lib_line = next(line for line in ninja.splitlines() if "libmylib" in line and line.startswith("build"))
self.assertIn(": link ", lib_line)
self.assertIn(": link_shared ", lib_line)
self.assertNotIn(": ar_rule", lib_line)

def test_shared_library_gets_lib_dirs_and_libs(self):
target = TargetConfig(
Expand All @@ -62,8 +65,15 @@ def test_static_library_unaffected(self):
ninja = self._generate("static", target)

self.assertIn(": ar_rule", ninja)
self.assertNotIn("-shared", ninja)
self.assertNotIn("-dynamiclib", ninja)

# The link_shared *rule* is always declared in the preamble, so the
# bare string "-shared" appears in every generated file. What must be
# absent is any build *edge* that uses it.
edges = [line for line in ninja.splitlines() if line.startswith("build ")]
self.assertTrue(edges, "no build edges were generated")
for edge in edges:
self.assertNotIn(": link_shared ", edge)
self.assertNotIn(": link ", edge)


if __name__ == "__main__":
Expand Down
Loading