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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ Observed in the source tree:

Requires Python 3.8+.

Building a `static_library` target also invokes that Python interpreter at
build time: the generated Ninja `ar_rule` runs a small helper to recreate the
archive so removed object members cannot linger.
```bash
pip install -e . # from the repo root
# or:
Expand Down
17 changes: 17 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,20 @@ passed, failed and skipped. A skipped test is not a passing test.

Any test that could not be run in this environment is named, with the reason,
and marked `NOT RUN` or `UNKNOWN` per [VERIFY.md](./VERIFY.md).

## Static-library source removal regression

Run `python -m pytest tests/unit/test_ninja_backend.py::TestStaticArchiveRecreation -v`.

The behavioral test drives Ninja with Python stub `cc`/`ar` tools (same launcher
pattern as `tests/unit/test_package_efw.py`), so it does not need a host C
toolchain. The stub archiver keeps omitted members the way real `ar r` does;
after a source is removed, the rebuilt archive must not list that object.

`ar_rule` recreates the archive when its build step runs: updating an existing
archive with `ar rcs` alone retains members removed from the source list. The
rule invokes `recreate_archive.py` by absolute path through the generating
Python interpreter, so Ninja needs that interpreter at build time for every
`static_library` edge. Regenerate `build.ninja` if the environment's Python or
the ebuild installation moves. The helper is not imported as `ebuild.*`, so a
bare checkout that only puts the package on `PYTHONPATH` still works.
19 changes: 18 additions & 1 deletion ebuild/build/ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from __future__ import annotations

import json
import shlex
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
Expand Down Expand Up @@ -187,6 +189,16 @@ def _object_path(self, target, src: str) -> Path:
def _write_ninja(self) -> None:
"""Write the build.ninja file."""
ninja_path = self.build_dir / "build.ninja"
# Invoke recreate_archive.py by absolute path so Ninja does not need
# `ebuild` on sys.path (cwd/PYTHONPATH checkouts, not only installs).
# Quote for the shell Ninja will use, and escape `$` so Ninja does not
# treat path fragments as variables.
helper = Path(__file__).with_name("recreate_archive.py")
archive_command = (
subprocess.list2cmdline([sys.executable, str(helper)])
if sys.platform == "win32"
else f"{shlex.quote(sys.executable)} {shlex.quote(str(helper))}"
).replace("$", "$$")
lines = [
f"# Generated by ebuild",
f"cc = {self.toolchain.cc}",
Expand All @@ -204,7 +216,12 @@ def _write_ninja(self) -> None:
" description = LINK $out",
"",
"rule ar_rule",
" command = $ar rcs $out $in",
# `ar rcs` replaces named members but keeps omitted ones, so an
# incremental rebuild after removing a source can leave its .o in
# the archive. recreate_archive deletes $out first. A path-based
# helper keeps this portable (no `rm`) without requiring `import
# ebuild` at build time.
f" command = {archive_command} $out $ar rcs $out $in",
" description = AR $out",
"",
# A shared_library edge names this rule. Without the rule the
Expand Down
79 changes: 79 additions & 0 deletions ebuild/build/recreate_archive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 EoS Project

"""Recreate a static archive for Ninja's ar_rule.

``ar rcs $out $in`` updates an existing archive: it replaces or adds the
members named in ``$in``, but keeps any other members already present. After a
source is removed from a static_library target, an incremental rebuild can
therefore leave the old ``.o`` inside ``$out``.

Ninja invokes this script by absolute path as::

python /path/to/recreate_archive.py $out $ar rcs $out $in

so the archive is deleted first and then rebuilt from the current object list.
A Python helper is used instead of ``rm`` so the same rule works on Windows.
The script is run by path (not ``python -m``) so a source checkout that only
puts ebuild on ``PYTHONPATH`` still builds static libraries.
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from pathlib import Path


def _resolve_archiver(archiver: str) -> str | None:
"""Return an absolute runnable archiver path, or None.

A bare name that is executable in the process cwd must become absolute:
``subprocess.call(["ar", …])`` uses PATH search and does not look in cwd,
so returning the relative name would pass the guard then fail after the
archive was already deleted.
"""
if os.path.isfile(archiver) and os.access(archiver, os.X_OK):
return os.path.abspath(archiver)
return shutil.which(archiver)


def main(argv: list[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if len(args) < 4:
print(
"ebuild: recreate_archive: expected $out $ar rcs $out …members",
file=sys.stderr,
)
return 2

archive = Path(args[0])
archiver = args[1]
# Refuse to destroy a good archive when the archiver cannot run.
resolved = _resolve_archiver(archiver)
if resolved is None:
reason = (
"present but not executable"
if os.path.exists(archiver)
else "not found"
)
print(f"ebuild: cannot run archiver {archiver}: {reason}", file=sys.stderr)
return 1

try:
archive.unlink(missing_ok=True)
except OSError as exc:
print(f"ebuild: cannot remove archive {archive}: {exc}", file=sys.stderr)
return 1

try:
return subprocess.call([resolved, *args[2:]])
except OSError as exc:
print(f"ebuild: cannot run archiver {archiver}: {exc}", file=sys.stderr)
return 1


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading