Files
ledgrab/build-common.sh
T
alexei.dolgolyov fd6776aeac
Lint & Test / test (push) Successful in 2m39s
fix(build): stop stripping zeroconf/_services + add import smoke test
- build-common.sh: remove zeroconf/_services from the strip list.
  zeroconf's compiled Cython _listener.pyd imports from _services
  internally, so stripping it broke `import zeroconf` at runtime with
  ModuleNotFoundError — same class of bug as the numpy.linalg strip.
- build-common.sh: add smoke_test_imports() that imports every top-level
  dependency against the stripped site-packages. Catches "we stripped
  something that was actually needed" regressions at build time instead
  of on a user's machine after install.
- build-dist.sh: wire smoke test into the Linux flow (runs real imports).
- build-dist-windows.sh: cross-build can't load win_amd64 .pyd files with
  the host python, so instead verify that the known-required submodule
  dirs (numpy.linalg/lib/matrixlib/ma, zeroconf._services) exist after
  cleanup. Fails loud if any future strip-rule removes them.
2026-04-07 23:32:50 +03:00

241 lines
10 KiB
Bash

#!/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
}
}