171 lines
8.6 KiB
Python
Executable File
171 lines
8.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Build configured Web probe targets independently and summarize artifacts.
|
|
|
|
The command intentionally keeps module artifacts separate. By default it
|
|
builds only targets belonging to the active module batch; use ``--all`` for a
|
|
release/CI sweep. A failed target is reported without preventing unrelated
|
|
configured targets from building.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from web_build_lock import run_build
|
|
import re
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BUILD_ROOT = ROOT / "freecad-source" / "build-web"
|
|
OUT = ROOT / "docs" / "current" / "web-module-build.json"
|
|
PRIMARY_TARGETS = {
|
|
"Assembly": "FreeCADWebAssemblyProbe",
|
|
"Cloud": "FreeCADWebCloudKernelProbe",
|
|
"Material": "FreeCADWebMaterialRuntimeProbe",
|
|
"Mesh": "FreeCADWebMeshRuntimeProbe",
|
|
"Part": "FreeCADWebPartBoxProbe",
|
|
}
|
|
|
|
|
|
def build_profile(build_dir: Path) -> str:
|
|
try:
|
|
cache = (build_dir / "CMakeCache.txt").read_text(encoding="utf-8")
|
|
except OSError:
|
|
return "unknown"
|
|
return "fast-dev" if "FREECAD_WEB_FAST_LINK:BOOL=ON" in cache else "release"
|
|
|
|
|
|
def configured_targets() -> list[tuple[Path, str]]:
|
|
cmake_file = ROOT / "freecad-source" / "src" / "WebAssembly" / "FreeCADCoreProbe" / "CMakeLists.txt"
|
|
try:
|
|
target_names = set(re.findall(r"add_executable\(\s*(FreeCADWeb[A-Za-z0-9]+Probe)", cmake_file.read_text(encoding="utf-8")))
|
|
except OSError:
|
|
target_names = set()
|
|
candidates: dict[str, list[Path]] = {}
|
|
for cache in sorted(BUILD_ROOT.glob("*/CMakeCache.txt")):
|
|
build_dir = cache.parent
|
|
ninja = build_dir / "build.ninja"
|
|
if not ninja.exists():
|
|
continue
|
|
try:
|
|
lines = ninja.read_text(encoding="utf-8", errors="ignore").splitlines()
|
|
except OSError:
|
|
continue
|
|
# Parse each Ninja file once. Rejoining the full file for every probe
|
|
# target made this discovery phase scale with
|
|
# ``target_count * build.ninja_size`` and dominated short no-op builds.
|
|
available_targets = set(
|
|
re.findall(r"^build (?:bin/)?(FreeCADWeb[A-Za-z0-9]+Probe)\.js:", "\n".join(lines), re.MULTILINE)
|
|
)
|
|
for target in target_names & available_targets:
|
|
candidates.setdefault(target, []).append(build_dir)
|
|
|
|
def rank(target: str, build_dir: Path) -> tuple[int, int, str]:
|
|
output = build_dir / "bin" / f"{target}.js"
|
|
existing = 0 if output.exists() and (build_dir / "bin" / f"{target}.wasm").exists() else 1
|
|
name = build_dir.name.lower()
|
|
preferred = 50
|
|
if target == "FreeCADWebFemGenericFamilyProbe" and "fem-wasm" in name: preferred = 0
|
|
elif target == "FreeCADWebTechDrawRuntimeProbe" and "techdraw-native" in name: preferred = 0
|
|
elif target.startswith("FreeCADWebAssembly") and "assembly" in name: preferred = 0
|
|
elif target == "FreeCADWebCAMProbe" and "cam" in name: preferred = 0
|
|
elif target == "FreeCADWebCloudKernelProbe" and "cloud" in name: preferred = 0
|
|
elif name == "configure": preferred = 10
|
|
return existing, preferred, str(build_dir)
|
|
|
|
return [(sorted(paths, key=lambda item: rank(target, item))[0], target) for target, paths in sorted(candidates.items())]
|
|
|
|
|
|
def current_batch_module() -> str | None:
|
|
try:
|
|
batch = json.loads((ROOT / "docs/current/module-batch-manifest.json").read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
selected = batch.get("selected") if isinstance(batch, dict) else None
|
|
module = selected.get("module") if isinstance(selected, dict) else None
|
|
return module if isinstance(module, str) and module else None
|
|
|
|
|
|
def target_matches_module(target: str, module: str) -> bool:
|
|
"""Keep implementation builds inside the active functional module."""
|
|
stem = target.removeprefix("FreeCADWeb").lower()
|
|
key = module.lower()
|
|
if module == "Part":
|
|
return stem.startswith("part") and not stem.startswith("partdesign")
|
|
if module == "MeshPart":
|
|
return stem.startswith("meshpart")
|
|
return stem.startswith(key)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--target", action="append", help="build only this target (repeatable)")
|
|
parser.add_argument("--all", action="store_true", help="build every configured target (release/CI use)")
|
|
parser.add_argument("--dry-run", action="store_true", help="print selected targets without building")
|
|
parser.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
|
|
parser.add_argument("--jobs", type=int, default=int(os.environ.get("FREECAD_BUILD_JOBS", os.cpu_count() or 1)))
|
|
parser.add_argument("--timeout", type=int, default=1800)
|
|
args = parser.parse_args()
|
|
targets = configured_targets()
|
|
selection = "all"
|
|
if args.target:
|
|
wanted = set(args.target)
|
|
targets = [item for item in targets if item[1] in wanted]
|
|
selection = "explicit"
|
|
elif not args.all and os.environ.get("FREECAD_WEB_BUILD_ALL", "0").lower() not in {"1", "true", "yes", "on"}:
|
|
module = current_batch_module()
|
|
if module:
|
|
module_targets = [item for item in targets if target_matches_module(item[1], module)]
|
|
if module_targets:
|
|
primary = PRIMARY_TARGETS.get(module)
|
|
preferred = [item for item in module_targets if item[1] == primary]
|
|
targets = preferred or module_targets
|
|
selection = f"active-module:{module}:primary" if preferred else f"active-module:{module}"
|
|
else:
|
|
targets = []
|
|
selection = f"active-module:{module}:no-target"
|
|
else:
|
|
targets = []
|
|
selection = "no-active-module"
|
|
if args.dry_run:
|
|
print(json.dumps({"selection": selection, "targets": [{"buildDirectory": str(directory.relative_to(ROOT)), "target": target} for directory, target in targets]}, ensure_ascii=False))
|
|
return 0
|
|
results = []
|
|
for build_dir, target in targets:
|
|
started = time.monotonic()
|
|
env = os.environ.copy()
|
|
env.setdefault("EM_CONFIG", str(ROOT / "tools" / "emscripten-writable-config.py"))
|
|
env.setdefault("EM_CACHE", "/tmp/freecad-p6-emcache")
|
|
env.pop("EM_FROZEN_CACHE", None)
|
|
env.pop("FROZEN_CACHE", None)
|
|
env.setdefault("EMCC_CORES", str(args.jobs))
|
|
preflight = subprocess.run(
|
|
[os.environ.get("PYTHON", "python3"), str(ROOT / "tools" / "check_web_link_closure.py"), str(build_dir), target, "--json"],
|
|
cwd=ROOT,
|
|
env=env,
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
if preflight.returncode != 0:
|
|
results.append({"buildDirectory": str(build_dir.relative_to(ROOT)), "target": target, "buildProfile": build_profile(build_dir), "exitCode": 78, "preflight": json.loads(preflight.stdout) if preflight.stdout else {"errors": [preflight.stderr[-1000:]]}, "durationSeconds": round(time.monotonic() - started, 3), "outputTail": (preflight.stdout + preflight.stderr)[-2000:]})
|
|
continue
|
|
command = ["cmake", "--build", str(build_dir), "--target", target, f"-j{args.jobs}"]
|
|
try:
|
|
completed = run_build(command, cwd=ROOT, env=env, text=True, capture_output=True, timeout=args.timeout, check=False)
|
|
results.append({"buildDirectory": str(build_dir.relative_to(ROOT)), "target": target, "buildProfile": build_profile(build_dir), "exitCode": completed.returncode, "preflight": json.loads(preflight.stdout) if preflight.stdout else {}, "durationSeconds": round(time.monotonic() - started, 3), "outputTail": (completed.stdout + completed.stderr)[-2000:]})
|
|
except subprocess.TimeoutExpired as error:
|
|
results.append({"buildDirectory": str(build_dir.relative_to(ROOT)), "target": target, "buildProfile": build_profile(build_dir), "exitCode": 124, "durationSeconds": round(time.monotonic() - started, 3), "outputTail": str(error)})
|
|
payload = {"schema": "freecad-web-module-build/v1", "generatedAt": datetime.now(timezone.utc).isoformat(), "jobs": args.jobs, "selection": selection, "targets": results, "summary": {"total": len(results), "passed": sum(item["exitCode"] == 0 for item in results), "failed": sum(item["exitCode"] != 0 for item in results)}}
|
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps(payload["summary"], ensure_ascii=False))
|
|
return 0 if payload["summary"]["failed"] == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|