#!/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 } # ── 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 wled_controller if pip-installed ─────────────── rm -rf "$sp_dir"/wled_controller* "$sp_dir"/wled*.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 and strip sources ───────────────── # # MUST run AFTER cleanup_site_packages (so we don't waste work compiling # files that are about to be deleted) and BEFORE any step that ships the # result. Uses `compileall -b` to produce legacy `foo.pyc` next to # `foo.py` (not `__pycache__/foo.cpython-XX.pyc`), which survives the # __pycache__ cleanup and works without a matching .py file at import. # # 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 } echo " Removing .py source (keeping .pyc)..." # Keep __init__.py so package discovery still works in edge cases # where namespace detection checks for the file. find "$target_dir" -name "*.py" ! -name "__init__.py" -delete 2>/dev/null || true # __pycache__ dirs are redundant now that we have legacy .pyc files find "$target_dir" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true } # ── Import smoke test ──────────────────────────────────────── # # Verifies that every top-level dependency that wled_controller 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 wled_controller) 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 for the app to start. # If you add a new top-level dependency, add it here. PYTHONPATH="$pypath" "$py_cmd" -c " import sys modules = [ 'numpy', 'numpy.linalg', 'numpy.lib', 'numpy.matrixlib', 'cv2', 'fastapi', 'uvicorn', 'starlette', 'pydantic', 'zeroconf', 'PIL', 'PIL.Image', 'yaml', ] failed = [] for mod in modules: try: __import__(mod) 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(' Smoke test passed (imported', len(modules), 'modules)') " || { echo " ERROR: smoke test failed — site-packages is broken, aborting build" return 1 } }