Skip to content
Merged
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
23 changes: 21 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ on:
pull_request:
branches: [master, main]

# Every other workflow in this repo declares a concurrency group; ci.yml,
# the heaviest one, did not. Pushing twice to a PR left the earlier run
# queued, and both competed for the same scarce windows/macos runners.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
# ── Test Matrix ───────────────────────────────────────────────────────────
test:
Expand All @@ -17,7 +24,10 @@ jobs:
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
os: [ubuntu-22.04, macos-13, windows-2022]
# macos-latest, not macos-13: the macos-13 image is retired, so jobs
# requesting it are never assigned a runner and sit queued until they
# time out. Every other workflow in this repo already uses macos-latest.
os: [ubuntu-22.04, macos-latest, windows-2022]
fail-fast: false

steps:
Expand All @@ -43,7 +53,11 @@ jobs:
continue-on-error: true

- name: Type check (mypy)
run: mypy . --ignore-missing-imports --no-strict-optional
# --exclude: layers/eosuite/ vendors its own tests/ package, so a bare
# `mypy .` sees two modules named "tests" and bails with "Duplicate
# module named 'tests'" before checking anything. continue-on-error hid
# that the type check was doing no work at all.
run: mypy . --ignore-missing-imports --no-strict-optional --exclude '^layers/'
continue-on-error: true

# Runs the whole tests/ tree. The previous steps ran only tests/unit/
Expand All @@ -57,7 +71,12 @@ jobs:
# coverage policy already lives in codecov.yml; whether to also
# enforce it here is a maintainer decision, so this change leaves
# both of those numbers alone.
# shell: bash -- the matrix includes windows-2022, where the default
# shell is PowerShell and the backslash line continuations below are a
# syntax error ("Missing expression after unary operator '--'"), so the
# Windows leg of this job failed before pytest ever started.
- name: Run test suite
shell: bash
run: |
python -m pytest tests/ -v --tb=short \
--cov=ebuild --cov-report=xml --cov-report=term-missing \
Expand Down
27 changes: 23 additions & 4 deletions ebuild/build/ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,25 @@ def _shared_flag() -> str:
return "-dynamiclib" if sys.platform == "darwin" else "-shared"


def _ninja_path(path) -> str:
"""Escape *path* for use in a Ninja build statement.

Ninja splits build statements on unescaped spaces and colons, so a Windows
absolute path writes a drive letter that Ninja reads as the output/rule
separator:

build C:\\...\\main.o: cc main.c
^ "expected build command name"

`$` is escaped first so the escapes introduced below are not re-escaped.
Only build statements need this; variable values (cflags, ldflags) are read
to end of line and must not be escaped, or the flags reach the compiler
mangled.
"""
text = str(path)
return text.replace("$", "$$").replace(":", "$:").replace(" ", "$ ")


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

Expand Down Expand Up @@ -167,7 +186,7 @@ def _write_ninja(self) -> None:
obj = str(self._object_path(target, src))
obj_files.append(obj)
lines.append(
f"build {obj}: cc {src}"
f"build {_ninja_path(obj)}: cc {_ninja_path(src)}"
)
if cflags:
lines.append(f" cflags = {' '.join(cflags)}")
Expand All @@ -194,7 +213,7 @@ def _write_ninja(self) -> None:
link_inputs = obj_files + dep_archives
out = str(self.build_dir / target.name)
lines.append(
f"build {out}: link {' '.join(link_inputs)}"
f"build {_ninja_path(out)}: link " f"{' '.join(_ninja_path(x) for x in link_inputs)}"
)
if ldflags:
lines.append(f" ldflags = {' '.join(ldflags)}")
Expand All @@ -212,7 +231,7 @@ def _write_ninja(self) -> None:
out = str(self.build_dir / f"lib{target.name}{ext}")

if target.target_type == "static_library":
lines.append(f"build {out}: ar_rule {' '.join(obj_files)}")
lines.append(f"build {_ninja_path(out)}: ar_rule " f"{' '.join(_ninja_path(x) for x in obj_files)}")
else:
# Shared libraries need the same -L/-l wiring executables
# get, which the rule preamble alone does not supply. The
Expand All @@ -228,7 +247,7 @@ def _write_ninja(self) -> None:
for lib in pkg.libraries:
libs.append(f"-l{lib}")

lines.append(f"build {out}: link_shared {' '.join(obj_files)}")
lines.append(f"build {_ninja_path(out)}: link_shared " f"{' '.join(_ninja_path(x) for x in obj_files)}")
if ldflags:
lines.append(f" ldflags = {' '.join(ldflags)}")
if libs:
Expand Down
6 changes: 5 additions & 1 deletion tests/ebuild/test_ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ def test_shared_library_uses_shared_link_rule(tmp_path):
NinjaBackend(config, tmp_path / "build", toolchain).generate()

ninja_file = (tmp_path / "build" / "build.ninja").read_text(encoding="utf-8")
assert "rule link_shared\n command = $cc -shared" in ninja_file
# macOS links dynamic libraries with -dynamiclib, ELF platforms with
# -shared. _shared_flag() picks the right one; asserting the literal
# "-shared" failed this test on every macOS runner.
shared_flag = "-dynamiclib" if sys.platform == "darwin" else "-shared"
assert f"rule link_shared\n command = $cc {shared_flag}" in ninja_file
assert "build " in ninja_file
assert ": link_shared " in ninja_file

Expand Down
11 changes: 10 additions & 1 deletion tests/unit/test_golden_path_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
regression guards for the steps that were missing or broken.
"""

import re
from pathlib import Path

import pytest
Expand Down Expand Up @@ -111,8 +112,16 @@ def test_test_target_links_like_an_executable(self, tmp_path):
SimpleNamespace(cc="cc", cxx="c++", ar="ar")).generate()
ninja = (tmp_path / "b" / "build.ninja").read_text(encoding="utf-8")

# Split on the first *unescaped* colon: that is the one separating
# outputs from the rule name. A plain l.split(":")[0] picks the drive
# letter apart from the rest on Windows, so the .o filter never matched
# and this selected the compile edge instead of the link edge.
def outputs(line):
return re.split(r"(?<!\$):", line, maxsplit=1)[0]

edge = next(l for l in ninja.splitlines()
if l.startswith("build ") and "t_smoke" in l and ".o" not in l.split(":")[0])
if l.startswith("build ") and "t_smoke" in l
and ".o" not in outputs(l))
assert ": link " in edge
assert ": ar_rule" not in edge

Expand Down
43 changes: 42 additions & 1 deletion tests/unit/test_ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pathlib import Path
from types import SimpleNamespace

from ebuild.build.ninja_backend import NinjaBackend
from ebuild.build.ninja_backend import NinjaBackend, _ninja_path
from ebuild.core.config import ProjectConfig, TargetConfig


Expand Down Expand Up @@ -76,5 +76,46 @@ def test_static_library_unaffected(self):
self.assertNotIn(": link ", edge)


class TestNinjaPathEscaping(unittest.TestCase):
"""Ninja splits build statements on unescaped spaces and colons.

A Windows absolute path puts a drive-letter colon into the output field, so
Ninja read the statement as a rule separator and rejected every generated
file with "expected build command name" -- the backend produced no usable
build on Windows at all. Paths in build statements must be escaped;
variable values must not be, or the flags reach the compiler mangled.
"""

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")

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")

def test_ordinary_posix_paths_are_unchanged(self):
self.assertEqual(_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):
with tempfile.TemporaryDirectory() as tmp:
build_dir = Path(tmp) / "b"
target = TargetConfig(name="app", target_type="executable",
sources=["main.c"])
config = ProjectConfig(name="proj", version="1.0", targets=[target],
source_dir=build_dir)
NinjaBackend(config, build_dir, _toolchain()).generate()
ninja = (build_dir / "build.ninja").read_text(encoding="utf-8")

for line in ninja.splitlines():
if not line.startswith("build "):
continue
# The only unescaped colon is the one separating outputs from
# the rule name.
stripped = line.replace("$:", "").replace("$$", "")
self.assertEqual(stripped.count(":"), 1, line)


if __name__ == "__main__":
unittest.main()
Loading