#!/usr/bin/env bash # # Shared build functions for LedGrab distribution packaging. # Sourced by build-dist.sh (Linux) and build-dist-windows.sh (Windows). # # Expected variables set by the caller before sourcing: # SCRIPT_DIR, BUILD_DIR, DIST_DIR, SERVER_DIR, APP_DIR # ── Version detection ──────────────────────────────────────── detect_version() { # Usage: detect_version [explicit_version] local version="${1:-}" if [ -z "$version" ]; then version=$(git describe --tags --exact-match 2>/dev/null || true) fi if [ -z "$version" ]; then version="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}" fi if [ -z "$version" ]; then version=$(grep -oP '^version\s*=\s*"\K[^"]+' "$SERVER_DIR/pyproject.toml" 2>/dev/null || echo "0.0.0") fi VERSION_CLEAN="${version#v}" # Normalize non-PEP440 version labels (e.g. "dev", "nightly", "snapshot") # to a valid PEP440 dev release. Without this, pip/setuptools rejects the # pyproject.toml with: `project.version` must be pep440. if ! [[ "$VERSION_CLEAN" =~ ^[0-9]+(\.[0-9]+)*((a|b|rc|\.dev|\.post)[0-9]+)*(\+[a-zA-Z0-9.]+)?$ ]]; then echo " Warning: '$VERSION_CLEAN' is not PEP440-compliant, using 0.0.0.dev0" VERSION_CLEAN="0.0.0.dev0" fi # Stamp the resolved version into pyproject.toml so that # importlib.metadata reads the correct value at runtime. sed -i "s/^version = .*/version = \"${VERSION_CLEAN}\"/" "$SERVER_DIR/pyproject.toml" } # ── Clean previous build ───────────────────────────────────── clean_dist() { if [ -d "$DIST_DIR" ]; then echo " Cleaning previous build..." rm -rf "$DIST_DIR" fi mkdir -p "$DIST_DIR" } # ── Build frontend ─────────────────────────────────────────── build_frontend() { echo " Building frontend bundle..." (cd "$SERVER_DIR" && npm ci --loglevel error && npm run build) 2>&1 | { grep -v 'RemoteException' || true } } # ── Copy application files ─────────────────────────────────── copy_app_files() { echo " Copying application files..." mkdir -p "$APP_DIR" cp -r "$SERVER_DIR/src" "$APP_DIR/src" cp -r "$SERVER_DIR/config" "$APP_DIR/config" mkdir -p "$DIST_DIR/data" "$DIST_DIR/logs" # Clean up source maps and __pycache__ find "$APP_DIR" -name "*.map" -delete 2>/dev/null || true find "$APP_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true # Patch the fallback version in the bundled __init__.py. Bundled installs # strip ledgrab-*.dist-info from site-packages, so importlib.metadata # falls back to this literal at runtime — and a stale literal is what # silently shipped v0.4.2 reporting "0.3.0" in the WebUI. local bundled_init="$APP_DIR/src/ledgrab/__init__.py" if [ -f "$bundled_init" ] && [ -n "${VERSION_CLEAN:-}" ]; then sed -i "s/_FALLBACK_VERSION = \"[^\"]*\"/_FALLBACK_VERSION = \"${VERSION_CLEAN}\"/" "$bundled_init" echo " Patched _FALLBACK_VERSION -> ${VERSION_CLEAN}" fi } # ── Site-packages cleanup ──────────────────────────────────── # # Strips tests, type stubs, unused submodules, and debug symbols # from the installed site-packages directory. # # Args: # $1 — path to site-packages directory # $2 — native extension suffix: "pyd" (Windows) or "so" (Linux) # $3 — native lib suffix for OpenCV ffmpeg: "dll" or "so" cleanup_site_packages() { local sp_dir="$1" local ext_suffix="${2:-so}" local lib_suffix="${3:-so}" echo " Cleaning up site-packages to reduce size..." # ── Generic cleanup ────────────────────────────────────── find "$sp_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$sp_dir" -type d -name tests -exec rm -rf {} + 2>/dev/null || true find "$sp_dir" -type d -name test -exec rm -rf {} + 2>/dev/null || true find "$sp_dir" -type d -name "*.dist-info" -exec rm -rf {} + 2>/dev/null || true find "$sp_dir" -name "*.pyi" -delete 2>/dev/null || true # ── pip / setuptools (not needed at runtime) ───────────── rm -rf "$sp_dir"/pip "$sp_dir"/pip-* 2>/dev/null || true rm -rf "$sp_dir"/setuptools "$sp_dir"/setuptools-* "$sp_dir"/pkg_resources 2>/dev/null || true rm -rf "$sp_dir"/_distutils_hack 2>/dev/null || true # ── OpenCV ─────────────────────────────────────────────── local cv2_dir="$sp_dir/cv2" if [ -d "$cv2_dir" ]; then # Remove ffmpeg (28 MB on Windows), Haar cascades, dev files rm -f "$cv2_dir"/opencv_videoio_ffmpeg*."$lib_suffix" 2>/dev/null || true rm -rf "$cv2_dir/data" "$cv2_dir/gapi" "$cv2_dir/misc" "$cv2_dir/utils" 2>/dev/null || true rm -rf "$cv2_dir/typing_stubs" "$cv2_dir/typing" 2>/dev/null || true fi # ── NumPy ──────────────────────────────────────────────── # Only strip modules that are safely unused by numpy's own import chain. # DO NOT strip: lib, linalg, ma, matrixlib — numpy.__init__ imports them # transitively (e.g. matrixlib → defmatrix → linalg), so removing any of # these breaks `import numpy` itself, cascading into every downstream # module. Learned the hard way in the v0.0.0.dev0 Windows build. for mod in polynomial distutils f2py typing _pyinstaller; do rm -rf "$sp_dir/numpy/$mod" 2>/dev/null || true done rm -rf "$sp_dir/numpy/tests" "$sp_dir/numpy/*/tests" 2>/dev/null || true # ── Pillow (only used for system tray icon) ────────────── rm -rf "$sp_dir/PIL/tests" 2>/dev/null || true # Remove unused image format plugins (keep JPEG, PNG, ICO, BMP) for plugin in Eps Gif Tiff Webp Psd Pcx Xbm Xpm Dds Ftex Gbr Grib \ Icns Im Imt Iptc McIrdas Mpo Msp Pcd Pixar Ppm Sgi \ Spider Sun Tga Wal Wmf; do rm -f "$sp_dir/PIL/${plugin}ImagePlugin.py" 2>/dev/null || true rm -f "$sp_dir/PIL/${plugin}ImagePlugin.pyc" 2>/dev/null || true done # ── zeroconf ───────────────────────────────────────────── # DO NOT strip zeroconf/_services — the compiled Cython _listener.pyd # imports from it, and the import fails at runtime with: # ModuleNotFoundError: No module named 'zeroconf._services' # Same class of bug as numpy — "presumed unused" submodule is actually # imported internally by the package's own compiled code. # ── Strip debug symbols ────────────────────────────────── if command -v strip &>/dev/null; then echo " Stripping debug symbols from .$ext_suffix files..." find "$sp_dir" -name "*.$ext_suffix" -exec strip --strip-debug {} \; 2>/dev/null || true fi # ── Remove ledgrab if pip-installed ─────────────── rm -rf "$sp_dir"/ledgrab* "$sp_dir"/ledgrab*.dist-info 2>/dev/null || true local cleaned_size cleaned_size=$(du -sh "$sp_dir" | cut -f1) echo " Site-packages after cleanup: $cleaned_size" } # ── Pre-compile .py → .pyc (keep sources) ──────────────────── # # MUST run AFTER cleanup_site_packages. Speeds up first startup by # producing .pyc alongside .py. We deliberately do NOT delete .py # sources afterwards: # # 1. OpenCV's loader does literal file I/O on cv2/config.py (not # an import) — stripping it breaks `import cv2` with: # "OpenCV loader: missing configuration file: ['config.py']". # 2. Other packages may do similar tricks (inspect.getsource, # runtime introspection, __file__-relative data loading). # 3. The size saving (~30%) isn't worth the whack-a-mole of # shipping broken installers. We already hit this with # numpy.linalg and zeroconf._services — enough incidents. # # Args: # $1 — directory to compile (site-packages or app/src) # $2 — python executable to use (default: python3) compile_and_strip_sources() { local target_dir="$1" local py_cmd="${2:-python3}" if [ ! -d "$target_dir" ]; then return 0 fi echo " Pre-compiling Python bytecode in $(basename "$target_dir")..." "$py_cmd" -m compileall -b -q "$target_dir" 2>/dev/null || { echo " ERROR: compileall failed for $target_dir — aborting" return 1 } # Drop __pycache__ to save the duplicated PEP-3147 copies; the # `-b` flag above placed legacy .pyc next to each .py, so nothing # of value is lost here. find "$target_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true } # ── Import smoke test ──────────────────────────────────────── # # Verifies that every top-level dependency that ledgrab actually # uses can be imported from the stripped site-packages. Catches regressions # where cleanup_site_packages removes a submodule that turns out to be # imported internally by the package (e.g. numpy.linalg, zeroconf._services). # Failing here is cheap; failing on a user's machine after install is not. # # Args: # $1 — path to site-packages to test against # $2 — python executable # $3 — (optional) extra PYTHONPATH entry (e.g. app/src for ledgrab) smoke_test_imports() { local sp_dir="$1" local py_cmd="${2:-python3}" local extra_path="${3:-}" echo " Running import smoke test..." local pypath="$sp_dir" if [ -n "$extra_path" ]; then pypath="$extra_path:$sp_dir" fi # Modules that MUST import cleanly IF PRESENT. We don't enforce # installation — Pillow for example is only a Windows dep. But if a # module's top-level package dir exists in site-packages and we # can't import it, that's a broken install and we abort. local smoke_script smoke_script=$(cat <<'PYEOF' import importlib import os import sys sp_dir = sys.argv[1] # (module_name, site-packages path to check for presence) candidates = [ ('numpy', 'numpy'), ('numpy.linalg', 'numpy/linalg'), ('numpy.lib', 'numpy/lib'), ('numpy.matrixlib', 'numpy/matrixlib'), ('cv2', 'cv2'), ('fastapi', 'fastapi'), ('uvicorn', 'uvicorn'), ('starlette', 'starlette'), ('pydantic', 'pydantic'), ('zeroconf', 'zeroconf'), ('zeroconf._services', 'zeroconf/_services'), ('PIL', 'PIL'), ('PIL.Image', 'PIL'), ('yaml', 'yaml'), ] tested = 0 skipped = 0 failed = [] for mod, path in candidates: if not os.path.exists(os.path.join(sp_dir, path)): skipped += 1 continue try: importlib.import_module(mod) tested += 1 except Exception as e: failed.append(f'{mod}: {type(e).__name__}: {e}') if failed: print('SMOKE TEST FAILED:', file=sys.stderr) for f in failed: print(f' {f}', file=sys.stderr) sys.exit(1) print(f' Smoke test passed ({tested} imported, {skipped} not installed)') PYEOF ) if ! PYTHONPATH="$pypath" "$py_cmd" -c "$smoke_script" "$sp_dir"; then echo " ERROR: smoke test failed — site-packages is broken, aborting build" return 1 fi }