Files
FreeCADWasmDev/tools/build_web_module_artifacts.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

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