From 0c3c32f5843224d24b5a9b08848726d8eb5f5297 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Sat, 6 Jun 2026 19:18:57 -0700 Subject: [PATCH] fix(installer): harden CUDA diagnostics with auto-remediation and behavioral tests (#451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(installer): surface pywhispercpp CUDA fallback diagnostics * fix(installer): add patchelf auto-remediation and behavioral tests - Add patchelf auto-remediation for bundled libcuda linkage issue - Add ARM64 CUDA toolkit path support (aarch64-linux, sbsa-linux) - Fix nvcc symlink resolution with readlink -f - Add --verbose flag to CPU pywhispercpp pip install - Add behavioral tests for CUDA diagnostic functions - Add string-presence test for patchelf remediation Fixes #450 * fix(installer): replace ★ with * to fix box border alignment The ★ character (U+2605) renders as 2 columns wide in most terminals, but the box padding assumes 1 column. This pushes the right border │ one column past the intended box edge, making it appear cut off. Replacing with ASCII * (always 1 column) fixes the alignment. --- install.sh | 316 +++++++++++++++++- tests/test_installer_cuda_diagnostics.py | 73 ++++ ...t_installer_cuda_diagnostics_behavioral.py | 301 +++++++++++++++++ 3 files changed, 678 insertions(+), 12 deletions(-) create mode 100644 tests/test_installer_cuda_diagnostics.py create mode 100644 tests/test_installer_cuda_diagnostics_behavioral.py diff --git a/install.sh b/install.sh index 0fd2af4..54bb5ee 100755 --- a/install.sh +++ b/install.sh @@ -4,6 +4,10 @@ set -e # Exit on error +# Keep the venv isolated from ~/.local site packages while still allowing +# --system-site-packages to expose distro-provided GTK/PyGObject bindings. +export PYTHONNOUSERSITE=1 + # Function to display colored output print_info() { echo -e "\e[1;34m[INFO]\e[0m $1" @@ -516,6 +520,141 @@ detect_nvidia_gpu() { fi } +cuda_toolkit_root_has_runtime_library() { + local CUDA_ROOT="$1" + + compgen -G "$CUDA_ROOT/lib64/libcudart.so*" >/dev/null && return 0 + compgen -G "$CUDA_ROOT/lib/libcudart.so*" >/dev/null && return 0 + compgen -G "$CUDA_ROOT/lib/x86_64-linux-gnu/libcudart.so*" >/dev/null && return 0 + compgen -G "$CUDA_ROOT/targets/x86_64-linux/lib/libcudart.so*" >/dev/null && return 0 + compgen -G "$CUDA_ROOT/targets/aarch64-linux/lib/libcudart.so*" >/dev/null && return 0 + compgen -G "$CUDA_ROOT/targets/sbsa-linux/lib/libcudart.so*" >/dev/null && return 0 + return 1 +} + +validate_cuda_toolkit_root() { + local CUDA_ROOT="$1" + + [ -n "$CUDA_ROOT" ] || return 1 + [ -x "$CUDA_ROOT/bin/nvcc" ] || return 1 + [ -f "$CUDA_ROOT/include/cuda_runtime.h" ] || return 1 + cuda_toolkit_root_has_runtime_library "$CUDA_ROOT" || return 1 +} + +candidate_cuda_toolkit_roots() { + local CUDA_VAR + for CUDA_VAR in CUDAToolkit_ROOT CUDA_HOME CUDA_PATH; do + local CUDA_ROOT="${!CUDA_VAR:-}" + [ -n "$CUDA_ROOT" ] && printf '%s\n' "$CUDA_ROOT" + done + + if command -v nvcc >/dev/null 2>&1; then + local NVCC_PATH + local NVCC_ROOT + NVCC_PATH=$(readlink -f "$(command -v nvcc)" 2>/dev/null || command -v nvcc) + NVCC_ROOT=$(cd "$(dirname "$NVCC_PATH")/.." 2>/dev/null && pwd -P) + [ -n "$NVCC_ROOT" ] && printf '%s\n' "$NVCC_ROOT" + fi + + local CUDA_ROOT + for CUDA_ROOT in /usr/local/cuda /usr/local/cuda-* /opt/cuda; do + [ -d "$CUDA_ROOT" ] && printf '%s\n' "$CUDA_ROOT" + done +} + +find_valid_cuda_toolkit_root() { + local QUIET="${1:-no}" + local SEEN_ROOTS="" + local CUDA_ROOT + + while IFS= read -r CUDA_ROOT; do + [ -n "$CUDA_ROOT" ] || continue + + local NORMALIZED_ROOT + NORMALIZED_ROOT=$(cd "$CUDA_ROOT" 2>/dev/null && pwd -P) || NORMALIZED_ROOT="$CUDA_ROOT" + + if [[ ":$SEEN_ROOTS:" == *":$NORMALIZED_ROOT:"* ]]; then + continue + fi + SEEN_ROOTS="${SEEN_ROOTS:+$SEEN_ROOTS:}$NORMALIZED_ROOT" + + if validate_cuda_toolkit_root "$NORMALIZED_ROOT"; then + printf '%s\n' "$NORMALIZED_ROOT" + return 0 + fi + + if [[ "$QUIET" != "quiet" ]]; then + print_warning "Ignoring incomplete CUDA toolkit root: $NORMALIZED_ROOT" >&2 + print_warning " Required: bin/nvcc, include/cuda_runtime.h, and libcudart.so*" >&2 + fi + done < <(candidate_cuda_toolkit_roots) + + return 1 +} + +detect_nvidia_compute_architectures() { + command -v nvidia-smi >/dev/null 2>&1 || return 1 + + local COMPUTE_CAPS + COMPUTE_CAPS=$(nvidia-smi --query-gpu=compute_cap --format=csv,noheader 2>/dev/null || true) + [ -n "$COMPUTE_CAPS" ] || return 1 + + printf '%s\n' "$COMPUTE_CAPS" | awk ' + { + gsub(/[[:space:]]/, "", $1) + if ($1 ~ /^[0-9]+(\.[0-9]+)?$/) { + split($1, parts, ".") + minor = parts[2] + if (minor == "") { + minor = "0" + } + print parts[1] minor "-real" + } + } + ' | sort -u | paste -sd ';' - +} + +cuda_toolkit_supports_architectures() { + local CUDA_ROOT="$1" + local CUDA_ARCHS="$2" + [ -n "$CUDA_ARCHS" ] || return 0 + + local NVCC_ARCHS + NVCC_ARCHS=$("$CUDA_ROOT/bin/nvcc" --list-gpu-arch 2>/dev/null || true) + if [ -z "$NVCC_ARCHS" ]; then + print_warning "Could not query supported CUDA architectures from $CUDA_ROOT/bin/nvcc" >&2 + print_warning "Continuing with detected CMAKE_CUDA_ARCHITECTURES=$CUDA_ARCHS" >&2 + return 0 + fi + + local IFS=';' + local CUDA_ARCH + for CUDA_ARCH in $CUDA_ARCHS; do + local ARCH_DIGITS="${CUDA_ARCH%%-*}" + if ! printf '%s\n' "$NVCC_ARCHS" | grep -q "compute_$ARCH_DIGITS"; then + print_warning "CUDA toolkit at $CUDA_ROOT cannot target compute capability $ARCH_DIGITS." >&2 + print_warning "Install a newer CUDA toolkit, such as CUDA 11.8+ for RTX 40/Ada GPUs." >&2 + return 1 + fi + done + + return 0 +} + +get_cuda_cmake_args() { + local CUDA_ROOT="$1" + local CUDA_ARGS="-DCUDAToolkit_ROOT=$CUDA_ROOT -DCMAKE_CUDA_COMPILER=$CUDA_ROOT/bin/nvcc" + local CUDA_ARCHS + + CUDA_ARCHS=$(detect_nvidia_compute_architectures || true) + if [ -n "$CUDA_ARCHS" ]; then + cuda_toolkit_supports_architectures "$CUDA_ROOT" "$CUDA_ARCHS" || return 1 + CUDA_ARGS="$CUDA_ARGS -DCMAKE_CUDA_ARCHITECTURES=$CUDA_ARCHS" + fi + + printf '%s\n' "$CUDA_ARGS" +} + # Detect Vulkan support for whisper.cpp detect_vulkan() { # Check for vulkaninfo command @@ -696,7 +835,7 @@ detect_whispercpp_backends() { # Check for CUDA local HAS_CUDA_DEV=false - if command -v nvcc >/dev/null 2>&1; then + if find_valid_cuda_toolkit_root quiet >/dev/null 2>&1; then HAS_CUDA_DEV=true fi @@ -866,7 +1005,7 @@ EOF print_header "Step 2: Choose Speech Recognition Engine" echo "" echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 1. WHISPER.CPP ★ RECOMMENDED │" + echo " │ 1. WHISPER.CPP * RECOMMENDED │" echo " │ • Fastest, most accurate, works with any GPU │" echo " │ • Supports NVIDIA (CUDA), AMD, Intel (Vulkan) │" echo " │ • CPU-only mode available for older systems │" @@ -983,7 +1122,7 @@ EOF if [[ "$CAN_BUILD_GPU" == "true" ]]; then echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 1. GPU (Vulkan/CUDA) ★ RECOMMENDED │" + echo " │ 1. GPU (Vulkan/CUDA) * RECOMMENDED │" echo " │ • Fastest performance with GPU acceleration │" echo " │ • $RECOMMENDED_REASON │" echo " │ • Requires building from source (takes ~2-5 min) │" @@ -1007,7 +1146,7 @@ EOF echo " └─────────────────────────────────────────────────────────────┘" echo "" echo " ┌─────────────────────────────────────────────────────────────┐" - echo " │ 2. CPU (Pre-built) ★ RECOMMENDED │" + echo " │ 2. CPU (Pre-built) * RECOMMENDED │" echo " │ • Works on all systems │" echo " │ • Fast installation (no compilation) │" echo " │ • Good performance on modern CPUs │" @@ -2108,6 +2247,7 @@ ACTIVATION_SCRIPT="$ACTIVATION_SCRIPT_DIR/activate-vocalinux.sh" cat > "$ACTIVATION_SCRIPT" << EOF #!/bin/bash # This script activates the Vocalinux virtual environment +export PYTHONNOUSERSITE=1 source "$VENV_DIR/bin/activate" echo "Vocalinux virtual environment activated." echo "To start the application, run: vocalinux" @@ -2134,7 +2274,7 @@ for attr in ("getsitepackages",): pass user_site = getattr(site, "getusersitepackages", lambda: None)() -if user_site: +if user_site and getattr(site, "ENABLE_USER_SITE", False): roots.append(user_site) for key in ("platlib", "purelib"): @@ -2183,6 +2323,124 @@ is_pywhispercpp_gpu_capable() { return 1 } +is_pywhispercpp_backend_capable() { + local BACKEND + BACKEND=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') + + local LIB_DIRS + LIB_DIRS=$(get_pywhispercpp_library_path || true) + [ -n "$LIB_DIRS" ] || return 1 + + local EXPECTED_LIB="" + case "$BACKEND" in + cuda) + EXPECTED_LIB="libggml-cuda.so" + ;; + vulkan) + EXPECTED_LIB="libggml-vulkan.so" + ;; + *) + return 1 + ;; + esac + + local IFS=: + local LIB_DIR + for LIB_DIR in $LIB_DIRS; do + if compgen -G "$LIB_DIR/$EXPECTED_LIB*" >/dev/null; then + return 0 + fi + done + + return 1 +} + +is_pywhispercpp_cuda_linkage_usable() { + if ! command -v readelf >/dev/null 2>&1; then + print_warning "readelf not found; skipping CUDA linkage verification." >&2 + return 0 + fi + + local LIB_DIRS + LIB_DIRS=$(get_pywhispercpp_library_path || true) + [ -n "$LIB_DIRS" ] || return 1 + + local HAS_PATCHELF=false + if command -v patchelf >/dev/null 2>&1; then + HAS_PATCHELF=true + fi + + local IFS=: + local LIB_DIR + for LIB_DIR in $LIB_DIRS; do + local CUDA_LIB + for CUDA_LIB in "$LIB_DIR"/libggml-cuda.so*; do + [ -f "$CUDA_LIB" ] || continue + local BUNDLED_LIBS + BUNDLED_LIBS=$(readelf -d "$CUDA_LIB" 2>/dev/null | sed -En 's/.*Shared library: \[(libcuda-[^]]+\.so).*/\1/p') + if [ -z "$BUNDLED_LIBS" ]; then + continue + fi + + local BUNDLED_LIB + while IFS= read -r BUNDLED_LIB; do + [ -n "$BUNDLED_LIB" ] || continue + print_warning "CUDA backend links against bundled $BUNDLED_LIB instead of libcuda.so.1:" + print_warning " $CUDA_LIB" + + if [[ "$HAS_PATCHELF" == "true" ]]; then + print_info "Attempting to relink with patchelf ($BUNDLED_LIB → libcuda.so.1)..." + if patchelf --replace-needed "$BUNDLED_LIB" libcuda.so.1 "$CUDA_LIB" 2>/dev/null; then + print_success "Successfully relinked $CUDA_LIB to libcuda.so.1" + else + print_warning "patchelf relink failed for $CUDA_LIB" + print_warning "Treating CUDA verification as failed so the installer does not report broken GPU support." + return 1 + fi + else + print_warning "Install patchelf to attempt automatic relinking: sudo apt install patchelf" + print_warning "Treating CUDA verification as failed so the installer does not report broken GPU support." + return 1 + fi + done <<< "$BUNDLED_LIBS" + done + done + + return 0 +} + +verify_pywhispercpp_backend_install() { + local BACKEND="$1" + + if ! is_pywhispercpp_installed; then + print_warning "pywhispercpp installed but import verification failed for $BACKEND backend." + return 1 + fi + + if ! is_pywhispercpp_backend_capable "$BACKEND"; then + print_warning "pywhispercpp installed but $BACKEND backend libraries were not found." + return 1 + fi + + if [[ "$BACKEND" == "CUDA" ]] && ! is_pywhispercpp_cuda_linkage_usable; then + return 1 + fi + + return 0 +} + +print_pip_log_tail() { + local PIP_LOG_FILE="$1" + local LINE_COUNT="${2:-80}" + + if [ -s "$PIP_LOG_FILE" ]; then + print_warning "Last $LINE_COUNT lines from pip build log ($PIP_LOG_FILE):" + tail -n "$LINE_COUNT" "$PIP_LOG_FILE" | sed 's/^/ /' + else + print_warning "Pip build log is empty or missing: $PIP_LOG_FILE" + fi +} + with_pywhispercpp_library_path() { local PYWHISPERCPP_LIBRARY_PATH PYWHISPERCPP_LIBRARY_PATH=$(get_pywhispercpp_library_path || true) @@ -2204,7 +2462,7 @@ install_cpu_pywhispercpp() { PYWHISPERCPP_CMAKE_ARGS=$(get_pywhispercpp_cmake_args) CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS" \ - pip install --force-reinstall --no-cache-dir pywhispercpp --log "$PIP_LOG_FILE" + pip install --verbose --force-reinstall --no-cache-dir pywhispercpp --log "$PIP_LOG_FILE" } is_pywhispercpp_installed() { @@ -2312,10 +2570,17 @@ install_whispercpp_with_gpu_support() { print_info " Installing pywhispercpp with Vulkan support..." GPU_BACKEND="Vulkan" print_info "Installing pywhispercpp ($GPU_BACKEND backend)..." - if CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS" GGML_VULKAN=1 pip install --force-reinstall --no-cache-dir git+https://github.com/absadiki/pywhispercpp --log "$PIP_LOG_FILE" 2>&1; then - GPU_INSTALL_SUCCESS=true + if CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS" \ + GGML_VULKAN=1 \ + pip install --verbose --force-reinstall --no-cache-dir git+https://github.com/absadiki/pywhispercpp --log "$PIP_LOG_FILE" 2>&1; then + if verify_pywhispercpp_backend_install "$GPU_BACKEND"; then + GPU_INSTALL_SUCCESS=true + else + print_pip_log_tail "$PIP_LOG_FILE" + fi else - print_warning "Vulkan build failed - checking for NVIDIA GPU to try CUDA..." + print_warning "Vulkan build failed; checking for NVIDIA GPU to try CUDA..." + print_pip_log_tail "$PIP_LOG_FILE" fi fi @@ -2324,9 +2589,31 @@ install_whispercpp_with_gpu_support() { print_info "✓ NVIDIA GPU detected: $GPU_NAME" print_info " Installing pywhispercpp with CUDA support..." GPU_BACKEND="CUDA" - print_info "Installing pywhispercpp ($GPU_BACKEND backend)..." - if CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS" GGML_CUDA=1 pip install --force-reinstall --no-cache-dir git+https://github.com/absadiki/pywhispercpp --log "$PIP_LOG_FILE" 2>&1; then - GPU_INSTALL_SUCCESS=true + + local CUDA_TOOLKIT_ROOT="" + local CUDA_CMAKE_ARGS="" + if CUDA_TOOLKIT_ROOT=$(find_valid_cuda_toolkit_root); then + if CUDA_CMAKE_ARGS=$(get_cuda_cmake_args "$CUDA_TOOLKIT_ROOT"); then + print_info "Using CUDA toolkit: $CUDA_TOOLKIT_ROOT" + print_info "Installing pywhispercpp ($GPU_BACKEND backend)..." + if CMAKE_ARGS="${CMAKE_ARGS:+$CMAKE_ARGS }$PYWHISPERCPP_CMAKE_ARGS $CUDA_CMAKE_ARGS" \ + GGML_CUDA=1 \ + pip install --verbose --force-reinstall --no-cache-dir git+https://github.com/absadiki/pywhispercpp --log "$PIP_LOG_FILE" 2>&1; then + if verify_pywhispercpp_backend_install "$GPU_BACKEND"; then + GPU_INSTALL_SUCCESS=true + else + print_pip_log_tail "$PIP_LOG_FILE" + fi + else + print_warning "CUDA build failed." + print_pip_log_tail "$PIP_LOG_FILE" + fi + else + print_warning "Skipping CUDA build because the detected toolkit cannot target this NVIDIA GPU." + fi + else + print_warning "No complete CUDA toolkit root found; skipping CUDA build." + print_info " Required CUDA files: bin/nvcc, include/cuda_runtime.h, and libcudart.so*" fi fi fi @@ -2351,6 +2638,9 @@ install_whispercpp_with_gpu_support() { print_info "ℹ No GPU detected - installing CPU-only version" print_info " CPU mode is still very fast!" fi + if [[ "$GPU_BACKEND" != "CPU" ]]; then + print_warning "Continuing with CPU-only pywhispercpp; GPU acceleration is not active." + fi GPU_BACKEND="CPU" print_info "Installing pywhispercpp ($GPU_BACKEND backend)..." install_cpu_pywhispercpp "$PIP_LOG_FILE" || { @@ -2774,6 +3064,7 @@ REMOTE_CONFIG #!/bin/bash # Wrapper script for Vocalinux that sets required environment variables # and applies the 'input' group for keyboard shortcuts on Wayland +export PYTHONNOUSERSITE=1 export GI_TYPELIB_PATH=$GI_TYPELIB_DETECTED PYWHISPERCPP_LIBRARY_PATH="" PY_SITE_PATHS=\$("$VENV_DIR/bin/python" - <<'PY' 2>/dev/null @@ -2818,6 +3109,7 @@ WRAPPER_EOF #!/bin/bash # Wrapper script for Vocalinux GUI that sets required environment variables # and applies the 'input' group for keyboard shortcuts on Wayland +export PYTHONNOUSERSITE=1 export GI_TYPELIB_PATH=$GI_TYPELIB_DETECTED PYWHISPERCPP_LIBRARY_PATH="" PY_SITE_PATHS=\$("$VENV_DIR/bin/python" - <<'PY' 2>/dev/null diff --git a/tests/test_installer_cuda_diagnostics.py b/tests/test_installer_cuda_diagnostics.py new file mode 100644 index 0000000..dd9109e --- /dev/null +++ b/tests/test_installer_cuda_diagnostics.py @@ -0,0 +1,73 @@ +"""Regression checks for installer CUDA diagnostics.""" + +import unittest +from pathlib import Path + +INSTALLER = Path(__file__).resolve().parents[1] / "install.sh" + + +def _installer_source() -> str: + return INSTALLER.read_text(encoding="utf-8") + + +class InstallerCudaDiagnosticsTests(unittest.TestCase): + def test_installer_disables_user_site_packages(self) -> None: + """Keep user-site packages out of the venv, activation script, and wrappers.""" + source = _installer_source() + + self.assertGreaterEqual(source.count("export PYTHONNOUSERSITE=1"), 4) + self.assertIn('getattr(site, "ENABLE_USER_SITE", False)', source) + + def test_cuda_toolkit_validation_requires_complete_root(self) -> None: + """CUDA roots must not be accepted when only a stale /usr/local/cuda exists.""" + source = _installer_source() + + self.assertIn("validate_cuda_toolkit_root()", source) + self.assertIn("$CUDA_ROOT/bin/nvcc", source) + self.assertIn("$CUDA_ROOT/include/cuda_runtime.h", source) + self.assertIn("$CUDA_ROOT/lib64/libcudart.so*", source) + self.assertIn("Ignoring incomplete CUDA toolkit root", source) + + def test_cuda_build_passes_explicit_cmake_toolkit_and_architecture(self) -> None: + """CUDA builds should steer CMake away from stale defaults.""" + source = _installer_source() + + self.assertIn("-DCUDAToolkit_ROOT=$CUDA_ROOT", source) + self.assertIn("-DCMAKE_CUDA_COMPILER=$CUDA_ROOT/bin/nvcc", source) + self.assertIn("-DCMAKE_CUDA_ARCHITECTURES=$CUDA_ARCHS", source) + self.assertIn("CUDA_CMAKE_ARGS=$(get_cuda_cmake_args", source) + self.assertIn("GGML_CUDA=1", source) + + def test_gpu_build_failures_print_pip_log_tail_before_cpu_fallback(self) -> None: + """Backend failures should expose the real pip/CMake log.""" + source = _installer_source() + + self.assertIn("print_pip_log_tail()", source) + self.assertIn("Vulkan build failed; checking for NVIDIA GPU to try CUDA", source) + self.assertIn("CUDA build failed.", source) + self.assertIn( + "Continuing with CPU-only pywhispercpp; GPU acceleration is not active.", + source, + ) + + def test_backend_verification_checks_gpu_libraries_and_cuda_linkage(self) -> None: + """Do not report GPU support unless native backend libraries exist.""" + source = _installer_source() + + self.assertIn("verify_pywhispercpp_backend_install()", source) + self.assertIn("libggml-vulkan.so", source) + self.assertIn("libggml-cuda.so", source) + self.assertIn("libcuda.so.1", source) + self.assertIn("libcuda-[^]]+\\.so", source) + + def test_patchelf_remediation_is_attempted_for_bundled_libcuda(self) -> None: + """Bundled libcuda should trigger patchelf relinking when available.""" + source = _installer_source() + + self.assertIn("patchelf", source) + self.assertIn("--replace-needed", source) + self.assertIn("readelf not found", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_installer_cuda_diagnostics_behavioral.py b/tests/test_installer_cuda_diagnostics_behavioral.py new file mode 100644 index 0000000..7307a7c --- /dev/null +++ b/tests/test_installer_cuda_diagnostics_behavioral.py @@ -0,0 +1,301 @@ +"""Behavioral tests for installer CUDA diagnostic functions.""" + +import os +import subprocess +import tempfile +from pathlib import Path + +import pytest + +INSTALL_SH = Path(__file__).resolve().parents[1] / "install.sh" + + +def source_install_sh_functions(tmpdir: Path) -> str: + """Extract and source only the function definitions from install.sh.""" + content = INSTALL_SH.read_text() + + setup_script = f""" +set -e +cd "{tmpdir}" + +# Mock output functions +print_warning() {{ echo "WARNING: $*" >&2; }} +print_info() {{ echo "INFO: $*" >&2; }} +print_success() {{ echo "SUCCESS: $*" >&2; }} +print_error() {{ echo "ERROR: $*" >&2; }} + +# Mock get_pywhispercpp_library_path (will be overridden per test) +get_pywhispercpp_library_path() {{ echo ""; }} + +# Source the actual functions from install.sh +# Extract function definitions using sed +""" + return setup_script + + +def run_bash_test(script: str) -> subprocess.CompletedProcess: + """Run a bash script and return the result.""" + result = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=10) + return result + + +class TestCudaToolkitRootHasRuntimeLibrary: + """Tests for cuda_toolkit_root_has_runtime_library function.""" + + def test_accepts_lib64_layout(self, tmp_path): + """Standard x86_64 layout with lib64/libcudart.so.""" + cuda_root = tmp_path / "cuda" + (cuda_root / "lib64").mkdir(parents=True) + (cuda_root / "lib64" / "libcudart.so.12.2").touch() + + script = f""" +{source_install_sh_functions(tmp_path)} +source <(sed -n '/^cuda_toolkit_root_has_runtime_library/,/^}}/p' "{INSTALL_SH}") +cuda_toolkit_root_has_runtime_library "{cuda_root}" && echo "PASS" || echo "FAIL" +""" + result = run_bash_test(script) + assert "PASS" in result.stdout + + def test_accepts_lib_layout(self, tmp_path): + """Layout with lib/libcudart.so.""" + cuda_root = tmp_path / "cuda" + (cuda_root / "lib").mkdir(parents=True) + (cuda_root / "lib" / "libcudart.so").touch() + + script = f""" +{source_install_sh_functions(tmp_path)} +source <(sed -n '/^cuda_toolkit_root_has_runtime_library/,/^}}/p' "{INSTALL_SH}") +cuda_toolkit_root_has_runtime_library "{cuda_root}" && echo "PASS" || echo "FAIL" +""" + result = run_bash_test(script) + assert "PASS" in result.stdout + + def test_accepts_debian_layout(self, tmp_path): + """Debian multiarch layout with lib/x86_64-linux-gnu/libcudart.so.""" + cuda_root = tmp_path / "cuda" + (cuda_root / "lib" / "x86_64-linux-gnu").mkdir(parents=True) + (cuda_root / "lib" / "x86_64-linux-gnu" / "libcudart.so.11.8").touch() + + script = f""" +{source_install_sh_functions(tmp_path)} +source <(sed -n '/^cuda_toolkit_root_has_runtime_library/,/^}}/p' "{INSTALL_SH}") +cuda_toolkit_root_has_runtime_library "{cuda_root}" && echo "PASS" || echo "FAIL" +""" + result = run_bash_test(script) + assert "PASS" in result.stdout + + def test_accepts_aarch64_layout(self, tmp_path): + """ARM64 layout with targets/aarch64-linux/lib/libcudart.so.""" + cuda_root = tmp_path / "cuda" + (cuda_root / "targets" / "aarch64-linux" / "lib").mkdir(parents=True) + (cuda_root / "targets" / "aarch64-linux" / "lib" / "libcudart.so.12.2").touch() + + script = f""" +{source_install_sh_functions(tmp_path)} +source <(sed -n '/^cuda_toolkit_root_has_runtime_library/,/^}}/p' "{INSTALL_SH}") +cuda_toolkit_root_has_runtime_library "{cuda_root}" && echo "PASS" || echo "FAIL" +""" + result = run_bash_test(script) + assert "PASS" in result.stdout + + def test_rejects_missing_runtime(self, tmp_path): + """Reject when libcudart.so* is missing.""" + cuda_root = tmp_path / "cuda" + cuda_root.mkdir(parents=True) + + script = f""" +{source_install_sh_functions(tmp_path)} +source <(sed -n '/^cuda_toolkit_root_has_runtime_library/,/^}}/p' "{INSTALL_SH}") +cuda_toolkit_root_has_runtime_library "{cuda_root}" && echo "FAIL" || echo "PASS" +""" + result = run_bash_test(script) + assert "PASS" in result.stdout + + +class TestValidateCudaToolkitRoot: + """Tests for validate_cuda_toolkit_root function.""" + + def test_accepts_complete_root(self, tmp_path): + """Accept when nvcc, cuda_runtime.h, and libcudart.so all present.""" + cuda_root = tmp_path / "cuda" + (cuda_root / "bin").mkdir(parents=True) + (cuda_root / "include").mkdir(parents=True) + (cuda_root / "lib64").mkdir(parents=True) + + nvcc = cuda_root / "bin" / "nvcc" + nvcc.touch() + nvcc.chmod(0o755) + + (cuda_root / "include" / "cuda_runtime.h").touch() + (cuda_root / "lib64" / "libcudart.so.12.2").touch() + + script = f""" +{source_install_sh_functions(tmp_path)} +source <(sed -n '/^cuda_toolkit_root_has_runtime_library/,/^}}/p' "{INSTALL_SH}") +source <(sed -n '/^validate_cuda_toolkit_root/,/^}}/p' "{INSTALL_SH}") +validate_cuda_toolkit_root "{cuda_root}" && echo "PASS" || echo "FAIL" +""" + result = run_bash_test(script) + assert "PASS" in result.stdout + + def test_rejects_stale_root(self, tmp_path): + """Reject when only bin/nvcc exists (stale /usr/local/cuda).""" + cuda_root = tmp_path / "cuda" + (cuda_root / "bin").mkdir(parents=True) + + nvcc = cuda_root / "bin" / "nvcc" + nvcc.touch() + nvcc.chmod(0o755) + + script = f""" +{source_install_sh_functions(tmp_path)} +source <(sed -n '/^cuda_toolkit_root_has_runtime_library/,/^}}/p' "{INSTALL_SH}") +source <(sed -n '/^validate_cuda_toolkit_root/,/^}}/p' "{INSTALL_SH}") +validate_cuda_toolkit_root "{cuda_root}" && echo "FAIL" || echo "PASS" +""" + result = run_bash_test(script) + assert "PASS" in result.stdout + + def test_rejects_nvcc_not_executable(self, tmp_path): + """Reject when nvcc exists but is not executable.""" + cuda_root = tmp_path / "cuda" + (cuda_root / "bin").mkdir(parents=True) + (cuda_root / "include").mkdir(parents=True) + (cuda_root / "lib64").mkdir(parents=True) + + (cuda_root / "bin" / "nvcc").touch() + (cuda_root / "include" / "cuda_runtime.h").touch() + (cuda_root / "lib64" / "libcudart.so.12.2").touch() + + script = f""" +{source_install_sh_functions(tmp_path)} +source <(sed -n '/^cuda_toolkit_root_has_runtime_library/,/^}}/p' "{INSTALL_SH}") +source <(sed -n '/^validate_cuda_toolkit_root/,/^}}/p' "{INSTALL_SH}") +validate_cuda_toolkit_root "{cuda_root}" && echo "FAIL" || echo "PASS" +""" + result = run_bash_test(script) + assert "PASS" in result.stdout + + +class TestDetectNvidiaComputeArchitecturesAwkParser: + """Tests for the awk parser in detect_nvidia_compute_architectures.""" + + def test_parses_single_gpu(self): + """Parse single GPU compute capability 8.9 (RTX 4090).""" + script = """ +echo "8.9" | awk ' + { + gsub(/[[:space:]]/, "", $1) + if ($1 ~ /^[0-9]+(\\.[0-9]+)?$/) { + split($1, parts, ".") + minor = parts[2] + if (minor == "") { + minor = "0" + } + print parts[1] minor "-real" + } + } +' | sort -u | paste -sd ';' - +""" + result = run_bash_test(script) + assert result.stdout.strip() == "89-real" + + def test_parses_multi_gpu(self): + """Parse multi-GPU with different architectures.""" + script = """ +echo -e "8.9\\n8.6" | awk ' + { + gsub(/[[:space:]]/, "", $1) + if ($1 ~ /^[0-9]+(\\.[0-9]+)?$/) { + split($1, parts, ".") + minor = parts[2] + if (minor == "") { + minor = "0" + } + print parts[1] minor "-real" + } + } +' | sort -u | paste -sd ';' - +""" + result = run_bash_test(script) + assert result.stdout.strip() == "86-real;89-real" + + def test_filters_garbage(self): + """Filter out non-numeric values like [Not Supported].""" + script = """ +echo -e "[Not Supported]\\n8.9" | awk ' + { + gsub(/[[:space:]]/, "", $1) + if ($1 ~ /^[0-9]+(\\.[0-9]+)?$/) { + split($1, parts, ".") + minor = parts[2] + if (minor == "") { + minor = "0" + } + print parts[1] minor "-real" + } + } +' | sort -u | paste -sd ';' - +""" + result = run_bash_test(script) + assert result.stdout.strip() == "89-real" + + def test_deduplicates(self): + """Deduplicate identical compute capabilities.""" + script = """ +echo -e "8.9\\n8.9\\n8.9" | awk ' + { + gsub(/[[:space:]]/, "", $1) + if ($1 ~ /^[0-9]+(\\.[0-9]+)?$/) { + split($1, parts, ".") + minor = parts[2] + if (minor == "") { + minor = "0" + } + print parts[1] minor "-real" + } + } +' | sort -u | paste -sd ';' - +""" + result = run_bash_test(script) + assert result.stdout.strip() == "89-real" + + +class TestReadelfBundledLibcudaRegex: + """Tests for the readelf regex that detects bundled libcuda.""" + + def test_detects_bundled_libcuda(self): + """Detect auditwheel-bundled libcuda with hashed name.""" + script = """ +echo " 0x0000000000000001 (NEEDED) Shared library: [libcuda-535.129.03.so]" | \ + sed -En 's/.*Shared library: \\[(libcuda-[^]]+\\.so).*/\\1/p' +""" + result = run_bash_test(script) + assert result.stdout.strip() == "libcuda-535.129.03.so" + + def test_ignores_system_libcuda(self): + """Do not match standard libcuda.so.1.""" + script = """ +echo " 0x0000000000000001 (NEEDED) Shared library: [libcuda.so.1]" | \ + sed -En 's/.*Shared library: \\[(libcuda-[^]]+\\.so).*/\\1/p' +""" + result = run_bash_test(script) + assert result.stdout.strip() == "" + + def test_ignores_bundled_cudart(self): + """Do not match bundled libcudart (different library).""" + script = """ +echo " 0x0000000000000001 (NEEDED) Shared library: [libcudart-12.2.140.so]" | \ + sed -En 's/.*Shared library: \\[(libcuda-[^]]+\\.so).*/\\1/p' +""" + result = run_bash_test(script) + assert result.stdout.strip() == "" + + def test_detects_hashed_libcuda(self): + """Detect auditwheel hash-style bundled libcuda.""" + script = """ +echo " 0x0000000000000001 (NEEDED) Shared library: [libcuda-abc12345.so]" | \ + sed -En 's/.*Shared library: \\[(libcuda-[^]]+\\.so).*/\\1/p' +""" + result = run_bash_test(script) + assert result.stdout.strip() == "libcuda-abc12345.so"