Files
nsis-plugin-ns7zip/tools/linux/build_plugin_linux.py
T
Simone cdb61af358 feat(linux): per-config + total wall-clock build timing
Print a timing summary (each config status+seconds and total wall
clock) for both parallel and sequential paths, mirroring the Windows
build output. Helps compare Linux vs Windows and track perf changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 22:05:14 +02:00

435 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Linux build path for nsis7z plugin.
Builds Windows DLL artifacts from Linux using MinGW-w64 cross compilers.
Supported versions: 7-Zip 25.01, 26.00 and zstd.
All project-owned build-infrastructure files (makefile.gcc, Nsis7z.def) live
under tools/linux/overlay/ and are never written into the vendor source trees.
Build artifacts go to _linux_build/<version>/<cfg>/ (gitignored).
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
# Symlink creation is shared across configs; serialize it so parallel
# per-config builds don't race recreating the bundle symlinks.
_SYMLINK_LOCK = threading.Lock()
ROOT = Path(__file__).resolve().parents[2]
OVERLAY = ROOT / "tools" / "linux" / "overlay"
BUILD_DIR = ROOT / "_linux_build"
SUPPORTED = {"25.01", "26.00", "26.01", "zstd"}
# For all versions the upstream vendor does NOT ship a Nsis7z bundle, so a
# *-bundle wrapper directory mirrors the vendor tree via symlinks and provides
# project-owned sources (NSIS UI, wrapper cpp files, vcxproj).
#
# For zstd the 7-zip C++ sources live in the 7-zip-zstd submodule while our
# NSIS wrapper sits in versions/zstd-bundle/. make is run with -C pointing to the
# wrapper dir and VENDOR_7ZIP overridden to the submodule tree so that
# include/source rules resolve correctly without touching the submodule.
VERSION_LAYOUT = {
"25.01": {
"vendor_7zip": ROOT / "versions" / "25.01" / "CPP" / "7zip",
"bundle_dir": ROOT / "versions" / "25.01-bundle" / "CPP" / "7zip" / "Bundles" / "Nsis7z",
},
"26.00": {
"vendor_7zip": ROOT / "versions" / "26.00" / "CPP" / "7zip",
"bundle_dir": ROOT / "versions" / "26.00-bundle" / "CPP" / "7zip" / "Bundles" / "Nsis7z",
},
"26.01": {
"vendor_7zip": ROOT / "versions" / "26.01" / "CPP" / "7zip",
"bundle_dir": ROOT / "versions" / "26.01-bundle" / "CPP" / "7zip" / "Bundles" / "Nsis7z",
},
# zstd: make runs in our wrapper dir; vendor_7zip points to submodule tree.
"zstd": {
"vendor_7zip": ROOT / "versions" / "7-zip-zstd" / "CPP" / "7zip",
"bundle_dir": ROOT / "versions" / "zstd-bundle" / "CPP" / "7zip" / "Bundles" / "Nsis7z",
},
}
# Per-version extra flags for GCC/MinGW cross-compilation that apply to both
# C and C++ compilations. Passed via LOCAL_FLAGS_EXTRA → merged into CFLAGS
# and CXXFLAGS alike.
VERSION_EXTRA_FLAGS: dict[str, str] = {
# fast-lzma2 (part of the 7-zip-zstd vendor) triggers -Wsign-compare and
# -Wcast-function-type on C code that was never compiled with GCC -Werror.
"zstd": "-Wno-sign-compare -Wno-cast-function-type",
}
# Per-version C++-only extra flags for GCC/MinGW cross-compilation.
# These compensate for vendor code written exclusively for MSVC.
# Passed via LOCAL_CXXFLAGS_EXTRA → appended to CXXFLAGS only (not CFLAGS).
#
# If flag-based suppression is insufficient (e.g. the code must be modified),
# place a patched copy at tools/linux/overlay/src/<version>/<relative-path>
# and add a rule override in makefile.gcc using the OVERLAY_SRC variable.
VERSION_EXTRA_CXXFLAGS: dict[str, str] = {
# 7-zip 25.01 NSIS UI code was never compiled with GCC:
# - STDMETHODIMP implementations lack `noexcept`/`throw()` while the COM
# interface macros (Z7_COM7F_IMP) declare the virtuals with `throw()`
# (= noexcept in C++17). Fixed per-TU via -include z7_idecl_noexcept_strip.h
# in the specific makefile rule for NsisExtractCallbackConsole.o.
# - Several unused parameters and sign-compare on UInt64 vs. literal -1.
"25.01": "-Wno-sign-compare -Wno-unused-parameter",
# 7-zip-zstd NSIS UI code has the same unused-parameter issues as 25.01.
# sign-compare is in C code (fast-lzma2) and already in VERSION_EXTRA_FLAGS;
# unused-parameter warnings are C++-only (NSIS wrapper and nsis7z.cpp).
"zstd": "-Wno-unused-parameter",
}
CONFIGS = {
"x86-ansi": {
"triplet": "i686-w64-mingw32",
"is_x64": "0",
"unicode": False,
"dest": "plugins/x86-ansi/nsis7z.dll",
},
"x86-unicode": {
"triplet": "i686-w64-mingw32",
"is_x64": "0",
"unicode": True,
"dest": "plugins/x86-unicode/nsis7z.dll",
},
"x64-unicode": {
"triplet": "x86_64-w64-mingw32",
"is_x64": "1",
"unicode": True,
"dest": "plugins/amd64-unicode/nsis7z.dll",
},
}
def _require_tool(name: str) -> None:
if shutil.which(name) is None:
print(f"ERROR: required tool not found: {name}", file=sys.stderr)
sys.exit(2)
def _resolve_jobs(value: str) -> int:
if value == "auto":
return max(1, os.cpu_count() or 1)
jobs = int(value)
if jobs < 1:
raise ValueError("jobs must be >= 1")
return jobs
def _ensure_bundle_symlinks(zip_version: str, bundle_dir: Path, vendor_7zip: Path) -> None:
"""Recreate bundle symlinks if they are missing (e.g. after a fresh clone).
Symlinks are no longer committed to git (they are POSIX-only and break
Windows clones), so they must be created on-the-fly on Linux before make
can resolve the relative source paths inside the bundle directory.
"""
# A sentinel: if the vendor C/ directory is not reachable via the bundle
# root symlink, assume all symlinks need to be (re)created.
sentinel = bundle_dir.parents[3] / "C" # bundle/CPP/7zip/../../../C = bundle/C
if sentinel.exists():
return # symlinks already in place
setup_script = ROOT / "tools" / "linux" / "setup_bundle_symlinks.py"
print(f"[linux] Bundle symlinks missing for {zip_version}, recreating via {setup_script.name} ...")
proc = subprocess.run(
[sys.executable, str(setup_script), zip_version],
cwd=ROOT,
)
if proc.returncode != 0:
print(f"ERROR: setup_bundle_symlinks.py failed for {zip_version}", file=sys.stderr)
sys.exit(proc.returncode)
def _build_one(
zip_version: str,
cfg_name: str,
verbose: bool,
jobs: int,
clean: bool,
cleanup_artifacts: bool,
dist: bool = False,
) -> int:
layout = VERSION_LAYOUT[zip_version]
vendor_7zip = layout["vendor_7zip"]
bundle_dir = layout["bundle_dir"]
# Symlinks are not committed to git; recreate them on Linux if missing.
# Serialized: concurrent per-config builds would race here.
if zip_version in VERSION_LAYOUT:
with _SYMLINK_LOCK:
_ensure_bundle_symlinks(zip_version, bundle_dir, vendor_7zip)
cfg = CONFIGS[cfg_name]
triplet = cfg["triplet"]
# Output objects go to an absolute path under _linux_build/ so nothing
# temporary is ever written into the vendor source trees.
out_dir = BUILD_DIR / zip_version / cfg_name
out_dir.mkdir(parents=True, exist_ok=True)
local_flags = "-DNDEBUG"
if cfg["unicode"]:
local_flags += " -DUNICODE -D_UNICODE"
else:
# Precomp.h forces UNICODE=1 unconditionally; suppress it for the ansi
# build so that LPTSTR resolves to char* consistently in all TUs.
local_flags += " -DZ7_NO_UNICODE"
# Version-specific workarounds for vendor code that was MSVC-only.
# Flags that are C++-only (e.g. -fpermissive) must go into LOCAL_CXXFLAGS_EXTRA
# which the makefile appends only to CXXFLAGS, not to CFLAGS.
extra_flags = VERSION_EXTRA_FLAGS.get(zip_version, "")
extra_cxx = VERSION_EXTRA_CXXFLAGS.get(zip_version, "")
# CXX_INCLUDE_FLAGS:
# 1. overlay/include case-sensitivity shims (Windows.h → windows.h, …)
# searched first so they shadow the actual MinGW system headers.
# 2. CPP root so vendor #include paths resolve from any working dir.
cpp_root = vendor_7zip.parent # …/CPP
overlay_inc = OVERLAY / "include"
# For bundle-based builds (zstd, 26.01) the UI/NSIS wrapper files live in
# our bundle dir, not in the vendor tree. Override NSIS_DIR accordingly.
bundle_nsis = bundle_dir.parent.parent / "UI" / "NSIS"
# The 7-zip-zstd vendor calls LocalFileTimeToFileTime2() which is defined
# in MyWindows.cpp. That TU is normally excluded from SYS_OBJS when
# IS_MINGW=1, so we must inject it back via EXTRA_SYS_OBJS.
extra_sys_objs = f"{out_dir}/MyWindows.o" if zip_version == "zstd" else ""
base_cmd = [
"make",
"-C", str(bundle_dir),
"-f", str(OVERLAY / "makefile.gcc"),
f"O={out_dir}",
f"DEF_FILE={OVERLAY / 'Nsis7z.def'}",
f"VENDOR_7ZIP={vendor_7zip}",
*([ f"NSIS_DIR={bundle_nsis}" ] if bundle_nsis.is_dir() else []),
"SystemDrive=1",
"IS_MINGW=1",
"MSYSTEM=LINUX",
f"IS_X64={cfg['is_x64']}",
f"CC={triplet}-gcc",
f"CXX={triplet}-g++",
f"RC={triplet}-windres",
f"LOCAL_FLAGS_EXTRA={local_flags} -Wno-unknown-pragmas {extra_flags}".rstrip(),
f"LOCAL_CXXFLAGS_EXTRA={extra_cxx}",
*([ f"EXTRA_SYS_OBJS={extra_sys_objs}" ] if extra_sys_objs else []),
# Linux ld is case-sensitive; vendor makefile uses mixed-case lib names
# (lUser32, lOle32, …) that don't exist on disk. Override LIB2 with
# fully-expanded lowercase equivalents.
"LIB2=-loleaut32 -luuid -ladvapi32 -luser32 -lole32 -lgdi32 -lcomctl32 -lcomdlg32 -lshell32 -lhtmlhelp",
f"CXX_INCLUDE_FLAGS=-I{overlay_inc} -I{cpp_root}",
]
build_mode = "clean build" if clean else "incremental build"
print(f"[linux] Building {cfg_name} ({zip_version}, {build_mode}, -j{jobs})")
# Don't run 'clean' and 'all' in the same parallel make invocation:
# with -j, GNU make may execute them concurrently and remove objects while
# the linker is already consuming them.
if clean:
proc = subprocess.run(base_cmd + ["clean"], cwd=bundle_dir)
if proc.returncode != 0:
return proc.returncode
proc = subprocess.run(base_cmd + [f"-j{jobs}", "all"], cwd=bundle_dir)
if proc.returncode != 0:
return proc.returncode
built = out_dir / "nsis7z.dll"
if not built.exists():
print(f"ERROR: expected artifact not found: {built}", file=sys.stderr)
return 1
rel_dest = cfg["dest"]
if dist and rel_dest.startswith("plugins/"):
rel_dest = "dist/" + rel_dest[len("plugins/"):]
dest = ROOT / rel_dest
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(built, dest)
if cleanup_artifacts:
shutil.rmtree(out_dir, ignore_errors=True)
if verbose:
print(f"[linux] Copied -> {dest}")
if cleanup_artifacts:
print(f"[linux] Cleaned build artifacts -> {out_dir}")
return 0
def _print_timing(
order: list[str],
codes: dict[str, int],
durations: dict[str, float],
wall: float,
) -> None:
"""Per-config + total wall-clock timing summary."""
print("\n" + "=" * 50)
print("[linux] Build timing")
print("-" * 50)
for cfg in order:
status = "OK " if codes.get(cfg) == 0 else "FAIL"
secs = durations.get(cfg)
secs_s = f"{secs:6.1f}s" if secs is not None else " N/A"
print(f" {status} {cfg:<13} {secs_s}")
print("-" * 50)
print(f" TOTAL wall clock {wall:6.1f}s")
print("=" * 50)
def main() -> int:
parser = argparse.ArgumentParser(description="Linux build path for nsis7z")
parser.add_argument(
"--7zip-version",
dest="zip_version",
choices=sorted(SUPPORTED),
default="26.01",
help="7-Zip version to build",
)
parser.add_argument(
"--configs",
nargs="+",
choices=list(CONFIGS.keys()) + ["all"],
default=["all"],
help="Build configuration(s): x86-ansi, x86-unicode, x64-unicode, or all",
)
parser.add_argument(
"--jobs",
default="auto",
help="Parallel make jobs. Use an integer or 'auto' (default: auto)",
)
parser.add_argument(
"--clean",
action="store_true",
default=True,
help="Run 'make clean' before building (default: True)",
)
parser.add_argument(
"--no-clean",
action="store_false",
dest="clean",
help="Skip 'make clean' to enable incremental rebuilds",
)
parser.add_argument(
"--cleanup-artifacts",
action="store_true",
default=True,
help="Remove per-config build artifact directories after successful copy (default: True)",
)
parser.add_argument(
"--no-cleanup-artifacts",
action="store_false",
dest="cleanup_artifacts",
help="Keep per-config build artifact directories (_o_*)",
)
parser.add_argument("--verbose", action="store_true", help="Verbose output")
parser.add_argument(
"--dist",
action="store_true",
help="Copy built DLLs to <repo>/dist/<config> instead of "
"<repo>/plugins/<config>",
)
parser.add_argument(
"--parallel",
action="store_true",
default=True,
help="Build configs concurrently (default: True)",
)
parser.add_argument(
"--no-parallel",
action="store_false",
dest="parallel",
help="Build configs one at a time",
)
args = parser.parse_args()
_require_tool("make")
for cfg in CONFIGS.values():
_require_tool(f"{cfg['triplet']}-gcc")
_require_tool(f"{cfg['triplet']}-g++")
_require_tool(f"{cfg['triplet']}-windres")
try:
jobs = _resolve_jobs(args.jobs)
except ValueError as exc:
print(f"ERROR: invalid --jobs value: {exc}", file=sys.stderr)
return 2
wanted = list(CONFIGS.keys()) if "all" in args.configs else args.configs
if args.parallel and len(wanted) > 1:
# Each config gets the FULL make -j (no //nconfigs split). The
# configs are independent; mild CPU oversubscription is handled
# by the scheduler and is still far faster than serial — this is
# exactly what the Windows (MSBuild) parallel build does, which
# finishes in ~2min on the same vCPU/RAM. Splitting -j across
# configs neutered intra-config parallelism (on a 4-vCPU VM it
# became make -j1) and gave no speedup.
per_jobs = jobs
print(
f"[linux] Building {len(wanted)} configs in parallel "
f"(make -j{per_jobs} each)"
)
codes: dict[str, int] = {}
durations: dict[str, float] = {}
def _timed(cfg: str, jb: int) -> None:
t0 = time.perf_counter()
codes[cfg] = _build_one(
args.zip_version, cfg, args.verbose, jb,
args.clean, args.cleanup_artifacts, args.dist,
)
durations[cfg] = time.perf_counter() - t0
wall0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=len(wanted)) as ex:
futs = [ex.submit(_timed, c, per_jobs) for c in wanted]
for fut in futs:
fut.result()
wall = time.perf_counter() - wall0
_print_timing(wanted, codes, durations, wall)
for name in wanted:
if codes[name] != 0:
return codes[name]
else:
codes = {}
durations = {}
wall0 = time.perf_counter()
for cfg_name in wanted:
t0 = time.perf_counter()
codes[cfg_name] = _build_one(
args.zip_version,
cfg_name,
args.verbose,
jobs,
args.clean,
args.cleanup_artifacts,
args.dist,
)
durations[cfg_name] = time.perf_counter() - t0
if codes[cfg_name] != 0:
_print_timing(wanted, codes, durations,
time.perf_counter() - wall0)
return codes[cfg_name]
_print_timing(wanted, codes, durations, time.perf_counter() - wall0)
print("[linux] Build completed")
return 0
if __name__ == "__main__":
sys.exit(main())