PoreGCN / setup_zeopp.py
catenate's picture
Use bundled Zeo++ tarball as primary source
de3213d verified
Raw
History Blame Contribute Delete
10 kB
"""
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__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Zeo++ 0.3 — official tarball from zeoplusplus.org
_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'
)
# Where this file lives (hf_space/)
_HERE = os.path.dirname(os.path.abspath(__file__))
# Target binary location consumed by config.py / build_graph.py
_BIN_DIR = os.path.join(_HERE, 'bin')
_BINARY = os.path.join(_BIN_DIR, 'network')
# Compile timeout (seconds). Cold-start on HF free CPU should finish in ~3 min.
_COMPILE_TIMEOUT = 300
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
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.
"""
# --- Already present ----------------------------------------------------
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
# --- Non-Linux: skip (no g++/make available without WSL) ----------------
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
# --- Check g++ and make are available ------------------------------------
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
# --- Attempt compilation -------------------------------------------------
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')
# Download
if not _download(tarball):
return False
# Unpack
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
# Locate unpacked directory (should be zeo++-0.3/)
src_dir = os.path.join(tmp, 'zeo++-0.3')
if not os.path.isdir(src_dir):
# Fallback: search for any directory containing Makefile
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)
# Step 1: compile voro++ (bundled dependency)
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:
# Older tarballs put voro++ directly in the root
logger.info('voro++/src not found; attempting top-level make directly.')
# Step 2: compile zeo++ (produces 'network')
if not _run_make(src_dir, label='zeo++'):
return False
# Step 3: copy binary
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
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
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.
"""
# 1. Bundled local tarball
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)
# Validate gzip magic
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)
# 2 & 3. Network fallback
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)
# ---------------------------------------------------------------------------
# CLI usage (for debugging on HPC/WSL)
# ---------------------------------------------------------------------------
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s')
ok = ensure_zeopp_binary()
sys.exit(0 if ok else 1)