#!/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())