| """ |
| setup_zeopp.py - Compile and install the Zeo++ 'network' binary at HF Space startup. |
| |
| Called from app.py BEFORE ensemble loading: |
| from setup_zeopp import ensure_zeopp_binary |
| |
| This module is a no-op on non-Linux systems (Windows, macOS) and when the binary |
| already exists. It is safe to call on every app restart; compilation is skipped |
| if bin/network is already present. |
| |
| Build strategy |
| -------------- |
| Zeo++ 0.3 bundles Voro++ as a subdirectory. The build sequence is: |
| 1. wget the official tarball from zeoplusplus.org into a temp dir. |
| 2. Untar -> zeo++-0.3/ |
| 3. cd zeo++-0.3/voro++/src && make -> libvoro++.a |
| 4. cd zeo++-0.3/ && make -> network binary |
| 5. Copy network -> bin/network (relative to this file) |
| 6. chmod +x |
| |
| packages.txt (apt) must list 'build-essential' so g++ and make are present |
| on the HF Space base image (Ubuntu 22.04). |
| |
| Windows / macOS |
| --------------- |
| On Windows, Zeo++ is not compiled automatically. The code skips the build and |
| returns False. build_graph.py already falls back to atom-only mode (n_pores=0) |
| when bin/network does not exist, so the app remains functional; pore nodes are |
| simply disabled. To enable pore nodes locally on Windows, install WSL2, compile |
| inside WSL, and copy the resulting 'network' binary to hf_space/bin/network. |
| |
| Fallback |
| -------- |
| If compilation fails for any reason (network unavailable, make error, timeout), |
| a WARNING is logged and the function returns False. build_graph.py detects the |
| missing binary and falls back to atom-only mode silently. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import os |
| import platform |
| import shutil |
| import stat |
| import subprocess |
| import sys |
| import tarfile |
| import tempfile |
| import urllib.request |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
|
|
| |
| _ZEOPP_URL = 'http://www.zeoplusplus.org/zeo++-0.3.tar.gz' |
| _ZEOPP_URL_FALLBACK = ( |
| 'https://raw.githubusercontent.com/richardjgowers/zeoplusplus/' |
| 'master/zeo%2B%2B-0.3.tar.gz' |
| ) |
|
|
| |
| _HERE = os.path.dirname(os.path.abspath(__file__)) |
|
|
| |
| _BIN_DIR = os.path.join(_HERE, 'bin') |
| _BINARY = os.path.join(_BIN_DIR, 'network') |
|
|
| |
| _COMPILE_TIMEOUT = 300 |
|
|
|
|
| |
| |
| |
|
|
| def ensure_zeopp_binary() -> bool: |
| """ |
| Ensure bin/network exists and is executable. |
| |
| Returns True if the binary is ready (either pre-existing or freshly compiled). |
| Returns False if compilation was skipped (non-Linux) or failed. |
| """ |
| |
| if os.path.isfile(_BINARY) and os.access(_BINARY, os.X_OK): |
| logger.info('Zeo++ binary already present at %s. Skipping build.', _BINARY) |
| return True |
|
|
| |
| if platform.system() != 'Linux': |
| logger.warning( |
| 'Zeo++ auto-compile is Linux-only. ' |
| 'System is %s. Running in atom-only mode. ' |
| 'To enable pore nodes locally, compile Zeo++ in WSL2 and copy ' |
| 'the resulting "network" binary to hf_space/bin/network.', |
| platform.system(), |
| ) |
| return False |
|
|
| |
| if shutil.which('g++') is None or shutil.which('make') is None: |
| logger.warning( |
| 'g++ or make not found. Cannot compile Zeo++. ' |
| 'Ensure packages.txt lists "build-essential". Atom-only mode.' |
| ) |
| return False |
|
|
| |
| logger.info('Compiling Zeo++ 0.3 from source. This takes ~1-3 minutes on first start.') |
|
|
| os.makedirs(_BIN_DIR, exist_ok=True) |
|
|
| with tempfile.TemporaryDirectory() as tmp: |
| tarball = os.path.join(tmp, 'zeo.tar.gz') |
|
|
| |
| if not _download(tarball): |
| return False |
|
|
| |
| try: |
| with tarfile.open(tarball, 'r:gz') as tf: |
| tf.extractall(tmp) |
| except Exception as exc: |
| logger.warning('Failed to unpack Zeo++ tarball: %s. Atom-only mode.', exc) |
| return False |
|
|
| |
| src_dir = os.path.join(tmp, 'zeo++-0.3') |
| if not os.path.isdir(src_dir): |
| |
| candidates = [ |
| d for d in os.listdir(tmp) |
| if os.path.isdir(os.path.join(tmp, d)) |
| and os.path.isfile(os.path.join(tmp, d, 'Makefile')) |
| ] |
| if not candidates: |
| logger.warning( |
| 'Could not locate Zeo++ source directory after unpack. ' |
| 'Atom-only mode.' |
| ) |
| return False |
| src_dir = os.path.join(tmp, candidates[0]) |
| logger.info('Using Zeo++ source directory: %s', src_dir) |
|
|
| |
| voro_src = os.path.join(src_dir, 'voro++', 'src') |
| if os.path.isdir(voro_src): |
| if not _run_make(voro_src, label='voro++'): |
| return False |
| else: |
| |
| logger.info('voro++/src not found; attempting top-level make directly.') |
|
|
| |
| if not _run_make(src_dir, label='zeo++'): |
| return False |
|
|
| |
| built_binary = os.path.join(src_dir, 'network') |
| if not os.path.isfile(built_binary): |
| logger.warning( |
| 'Compilation appeared to succeed but "network" binary not found at %s. ' |
| 'Atom-only mode.', |
| built_binary, |
| ) |
| return False |
|
|
| shutil.copy2(built_binary, _BINARY) |
| _make_executable(_BINARY) |
| logger.info('Zeo++ binary installed at %s.', _BINARY) |
| return True |
|
|
|
|
| |
| |
| |
|
|
| def _download(dest: str) -> bool: |
| """Locate Zeo++ tarball. |
| |
| Priority: |
| 1. Bundled tarball at hf_space/zeo++-0.3.tar.gz (preferred — no network). |
| 2. Primary URL on zeoplusplus.org. |
| 3. Fallback URL on github. |
| |
| HF Spaces outbound network has been observed to corrupt the tarball download |
| on some runtimes (the file arrives larger than 10 KB but is not valid gzip), |
| which is why bundling the tarball in the repo is the primary path. |
| """ |
| |
| local = os.path.join(_HERE, 'zeo++-0.3.tar.gz') |
| if os.path.isfile(local) and os.path.getsize(local) > 1_000_000: |
| try: |
| shutil.copy2(local, dest) |
| |
| with open(dest, 'rb') as f: |
| head = f.read(2) |
| if head == b'\x1f\x8b': |
| logger.info('Using bundled Zeo++ tarball (%d bytes).', os.path.getsize(dest)) |
| return True |
| logger.warning('Bundled tarball is not a valid gzip. Falling back to download.') |
| except Exception as exc: |
| logger.warning('Bundled tarball copy failed: %s. Falling back to download.', exc) |
|
|
| |
| for url in (_ZEOPP_URL, _ZEOPP_URL_FALLBACK): |
| try: |
| logger.info('Downloading Zeo++ from %s ...', url) |
| urllib.request.urlretrieve(url, dest) |
| with open(dest, 'rb') as f: |
| head = f.read(2) |
| if head == b'\x1f\x8b' and os.path.getsize(dest) > 1_000_000: |
| logger.info('Download complete: %d bytes.', os.path.getsize(dest)) |
| return True |
| logger.warning('Downloaded file from %s is not a valid gzip. Trying next URL.', url) |
| except Exception as exc: |
| logger.warning('Download failed from %s: %s. Trying next URL.', url, exc) |
| logger.warning('All Zeo++ sources failed. Atom-only mode.') |
| return False |
|
|
|
|
| def _run_make(directory: str, label: str = '') -> bool: |
| """Run 'make' in directory. Returns True on success.""" |
| try: |
| result = subprocess.run( |
| ['make'], |
| cwd=directory, |
| check=True, |
| capture_output=True, |
| text=True, |
| timeout=_COMPILE_TIMEOUT, |
| ) |
| logger.info('make %s succeeded.', label) |
| if result.stdout: |
| logger.debug('make %s stdout: %s', label, result.stdout[-500:]) |
| return True |
| except subprocess.TimeoutExpired: |
| logger.warning('make %s timed out after %ds. Atom-only mode.', label, _COMPILE_TIMEOUT) |
| except subprocess.CalledProcessError as exc: |
| logger.warning( |
| 'make %s failed (exit %d):\n%s\nAtom-only mode.', |
| label, exc.returncode, (exc.stderr or '')[-1000:], |
| ) |
| except Exception as exc: |
| logger.warning('make %s unexpected error: %s. Atom-only mode.', label, exc) |
| return False |
|
|
|
|
| def _make_executable(path: str) -> None: |
| """chmod +x path.""" |
| st = os.stat(path) |
| os.chmod(path, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) |
|
|
|
|
| |
| |
| |
| if __name__ == '__main__': |
| logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s') |
| ok = ensure_zeopp_binary() |
| sys.exit(0 if ok else 1) |
|
|