|
| 1 | +import argparse |
| 2 | +import os |
| 3 | +import platform |
| 4 | +import shutil |
| 5 | +import stat |
| 6 | +import subprocess |
| 7 | +import tarfile |
| 8 | +import tempfile |
| 9 | +from pathlib import Path |
| 10 | +from typing import Optional |
| 11 | +from urllib.request import urlretrieve |
| 12 | + |
| 13 | + |
| 14 | +MMG_VERSION_DEFAULT = "5.8.0" |
| 15 | +MMG_BASENAMES = ("mmg2d_O3", "mmg3d_O3", "mmgs_O3") |
| 16 | + |
| 17 | + |
| 18 | +def _norm_os(os_name: str) -> str: |
| 19 | + if os_name.lower() in {"linux"}: |
| 20 | + return "Linux" |
| 21 | + if os_name.lower() in {"darwin", "mac", "macos"}: |
| 22 | + return "Mac" |
| 23 | + if os_name.lower() in {"windows", "win"}: |
| 24 | + return "Windows" |
| 25 | + raise ValueError(f"Unsupported OS: {os_name}") |
| 26 | + |
| 27 | + |
| 28 | +def _norm_arch(arch: str) -> str: |
| 29 | + a = arch.strip().lower() |
| 30 | + if a in {"x86_64", "amd64"}: |
| 31 | + return "x86_64" |
| 32 | + if a in {"aarch64", "arm64"}: |
| 33 | + return "aarch64" |
| 34 | + if a in {"universal2"}: |
| 35 | + return "universal2" |
| 36 | + raise ValueError(f"Unsupported arch: {arch}") |
| 37 | + |
| 38 | + |
| 39 | +def _tar_safe_extract(t: tarfile.TarFile, dest: Path) -> None: |
| 40 | + dest = dest.resolve() |
| 41 | + for member in t.getmembers(): |
| 42 | + member_path = (dest / member.name).resolve() |
| 43 | + if not str(member_path).startswith(str(dest) + os.sep): |
| 44 | + raise RuntimeError(f"Unsafe tar path: {member.name}") |
| 45 | + t.extractall(dest) |
| 46 | + |
| 47 | + |
| 48 | +def _chmod_x(path: Path) -> None: |
| 49 | + if os.name == "nt": |
| 50 | + return |
| 51 | + try: |
| 52 | + mode = path.stat().st_mode |
| 53 | + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) |
| 54 | + except Exception: |
| 55 | + pass |
| 56 | + |
| 57 | + |
| 58 | +def _run(cmd: list[str], *, cwd: Optional[Path] = None) -> None: |
| 59 | + print("+", " ".join(cmd), flush=True) |
| 60 | + subprocess.check_call(cmd, cwd=str(cwd) if cwd else None) |
| 61 | + |
| 62 | + |
| 63 | +def _find_built_mmg_exes(prefix: Path) -> dict[str, Path]: |
| 64 | + found: dict[str, Path] = {} |
| 65 | + for p in prefix.rglob("*"): |
| 66 | + if not p.is_file(): |
| 67 | + continue |
| 68 | + name = p.name |
| 69 | + if os.name == "nt": |
| 70 | + if not name.lower().endswith(".exe"): |
| 71 | + continue |
| 72 | + stem = p.stem |
| 73 | + if stem in MMG_BASENAMES: |
| 74 | + found[stem] = p |
| 75 | + else: |
| 76 | + if name in MMG_BASENAMES: |
| 77 | + found[name] = p |
| 78 | + return found |
| 79 | + |
| 80 | + |
| 81 | +def build_mmg(*, version: str, os_name: str, arch: str, out_dir: Path, jobs: int) -> None: |
| 82 | + os_name = _norm_os(os_name) |
| 83 | + arch = _norm_arch(arch) |
| 84 | + out_dir.mkdir(parents=True, exist_ok=True) |
| 85 | + |
| 86 | + url = f"https://github.com/MmgTools/mmg/archive/refs/tags/v{version}.tar.gz" |
| 87 | + |
| 88 | + with tempfile.TemporaryDirectory() as td: |
| 89 | + tmp = Path(td) |
| 90 | + tar_path = tmp / "mmg.tar.gz" |
| 91 | + src_root = tmp / "src" |
| 92 | + build_dir = tmp / "build" |
| 93 | + install_dir = tmp / "install" |
| 94 | + src_root.mkdir(parents=True, exist_ok=True) |
| 95 | + build_dir.mkdir(parents=True, exist_ok=True) |
| 96 | + install_dir.mkdir(parents=True, exist_ok=True) |
| 97 | + |
| 98 | + print(f"Downloading MMG v{version}...", flush=True) |
| 99 | + urlretrieve(url, tar_path) # nosec - trusted upstream in CI/release flows |
| 100 | + |
| 101 | + print("Extracting MMG...", flush=True) |
| 102 | + with tarfile.open(tar_path, "r:gz") as t: |
| 103 | + _tar_safe_extract(t, src_root) |
| 104 | + |
| 105 | + # Archive extracts into mmg-<ver>/ |
| 106 | + subdirs = [p for p in src_root.iterdir() if p.is_dir()] |
| 107 | + if not subdirs: |
| 108 | + raise RuntimeError("MMG source extraction produced no subdirectory") |
| 109 | + mmg_src = subdirs[0] |
| 110 | + |
| 111 | + cmake_cmd = [ |
| 112 | + "cmake", |
| 113 | + "-S", |
| 114 | + str(mmg_src), |
| 115 | + "-B", |
| 116 | + str(build_dir), |
| 117 | + "-DCMAKE_BUILD_TYPE=Release", |
| 118 | + f"-DCMAKE_INSTALL_PREFIX={install_dir}", |
| 119 | + "-DCMAKE_C_FLAGS_RELEASE=-O3 -DNDEBUG", |
| 120 | + ] |
| 121 | + |
| 122 | + if os_name == "Mac" and arch == "universal2": |
| 123 | + # Build fat binaries. |
| 124 | + deployment = os.environ.get("MACOSX_DEPLOYMENT_TARGET", "11.0").strip() |
| 125 | + cmake_cmd += [ |
| 126 | + "-DCMAKE_OSX_ARCHITECTURES=x86_64;arm64", |
| 127 | + f"-DCMAKE_OSX_DEPLOYMENT_TARGET={deployment}", |
| 128 | + ] |
| 129 | + |
| 130 | + if os_name == "Windows": |
| 131 | + # Prefer a 64-bit build when using Visual Studio generators. |
| 132 | + cmake_cmd += ["-A", "x64"] |
| 133 | + |
| 134 | + _run(cmake_cmd) |
| 135 | + |
| 136 | + build_cmd = ["cmake", "--build", str(build_dir), "--parallel", str(max(1, jobs))] |
| 137 | + if os_name == "Windows": |
| 138 | + build_cmd += ["--config", "Release"] |
| 139 | + _run(build_cmd) |
| 140 | + |
| 141 | + install_cmd = ["cmake", "--install", str(build_dir)] |
| 142 | + if os_name == "Windows": |
| 143 | + install_cmd += ["--config", "Release"] |
| 144 | + _run(install_cmd) |
| 145 | + |
| 146 | + found = _find_built_mmg_exes(install_dir) |
| 147 | + missing = sorted(set(MMG_BASENAMES) - set(found.keys())) |
| 148 | + if missing: |
| 149 | + raise RuntimeError(f"MMG build succeeded but executables not found: {missing}") |
| 150 | + |
| 151 | + # Copy into the repo/package layout |
| 152 | + for stem in MMG_BASENAMES: |
| 153 | + src = found[stem] |
| 154 | + dest = out_dir / src.name |
| 155 | + if dest.exists(): |
| 156 | + dest.unlink() |
| 157 | + shutil.copy2(src, dest) |
| 158 | + _chmod_x(dest) |
| 159 | + |
| 160 | + print("MMG installed to:", out_dir, flush=True) |
| 161 | + for stem in MMG_BASENAMES: |
| 162 | + exe = out_dir / (stem + (".exe" if os.name == "nt" else "")) |
| 163 | + if exe.exists(): |
| 164 | + print(" -", exe, flush=True) |
| 165 | + |
| 166 | + |
| 167 | +def main() -> None: |
| 168 | + parser = argparse.ArgumentParser(description="Build and stage MMG executables for svv packaging.") |
| 169 | + parser.add_argument("--version", default=MMG_VERSION_DEFAULT, help="MMG version (default: 5.8.0)") |
| 170 | + parser.add_argument("--os", dest="os_name", default=platform.system(), help="Target OS (Linux/Mac/Windows)") |
| 171 | + parser.add_argument("--arch", default=platform.machine(), help="Target arch (x86_64/aarch64/universal2)") |
| 172 | + parser.add_argument( |
| 173 | + "--out-dir", |
| 174 | + default=None, |
| 175 | + help="Output directory for executables (default: svv/utils/remeshing/<OS>/<arch>)", |
| 176 | + ) |
| 177 | + parser.add_argument("--jobs", type=int, default=(os.cpu_count() or 1), help="Parallel build jobs") |
| 178 | + args = parser.parse_args() |
| 179 | + |
| 180 | + os_name = _norm_os(args.os_name) |
| 181 | + arch = _norm_arch(args.arch) |
| 182 | + |
| 183 | + repo_root = Path(__file__).resolve().parents[2] |
| 184 | + default_out = repo_root / "svv" / "utils" / "remeshing" / os_name / arch |
| 185 | + out_dir = Path(args.out_dir).resolve() if args.out_dir else default_out |
| 186 | + |
| 187 | + build_mmg(version=args.version, os_name=os_name, arch=arch, out_dir=out_dir, jobs=args.jobs) |
| 188 | + |
| 189 | + |
| 190 | +if __name__ == "__main__": |
| 191 | + main() |
0 commit comments