Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 56 additions & 157 deletions lib/src/backend_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ def run_sudo_command(cmd: list, check: bool = True, input_data: Optional[bytes]
STATE_DIR = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local' / 'state')) / 'hyprwhspr'
STATE_FILE = STATE_DIR / 'install-state.json'
PYWHISPERCPP_SRC_DIR = USER_BASE / 'pywhispercpp-src'
PYWHISPERCPP_PINNED_COMMIT = "294e1e15f1fa3991aaa8db5f5e9afb97ade5ba5f"
PYWHISPERCPP_PINNED_COMMIT = "f7bf62118c0a33a43cf8aabb58eef16cea5d16c4"

# Pre-built wheel configuration
WHEEL_BASE_URL = "https://github.com/goodroot/hyprwhspr/releases/download/wheels-v2"
WHEEL_CACHE_DIR = USER_BASE / 'wheel-cache'
PYWHISPERCPP_VERSION = "1.5.0"
PYWHISPERCPP_VERSION = "1.5.1"


class DependencyPlanError(RuntimeError):
Expand Down Expand Up @@ -1965,24 +1965,18 @@ def install_pywhispercpp_cpu(pip_bin: Path, requirements_file: Path) -> bool:
temp_req_path.unlink()


def install_pywhispercpp_cuda(pip_bin: Path) -> bool:
"""Install pywhispercpp with CUDA support"""
log_info("Installing pywhispercpp with CUDA support...")
def _prepare_pywhispercpp_sources() -> bool:
"""Clean stale build artifacts and put the pywhispercpp checkout on the pinned commit.

# Try pre-built wheel first (much faster than source build)
wheel_path = download_pywhispercpp_wheel() # Auto-detects CUDA version
if wheel_path:
if install_pywhispercpp_from_wheel(pip_bin, wheel_path):
return True
log_warning("Pre-built wheel failed, falling back to source build...")

install_system_dependencies()
log_info("Building from source (this may take several minutes)...")
Returns False if the tree does not end up on PYWHISPERCPP_PINNED_COMMIT: building from
a stale checkout silently reintroduces whatever the pin bump was meant to fix.
"""
verbosity = OutputController.get_verbosity()
verbose = verbosity.value >= VerbosityLevel.VERBOSE.value

# Clean build artifacts if they exist (to avoid Python version mismatches)
if PYWHISPERCPP_SRC_DIR.exists():
log_info("Cleaning existing build artifacts...")
import shutil
# Remove common build directories
build_dirs = [
PYWHISPERCPP_SRC_DIR / 'build',
Expand All @@ -1993,30 +1987,28 @@ def install_pywhispercpp_cuda(pip_bin: Path) -> bool:
for build_dir in build_dirs:
if build_dir.exists():
shutil.rmtree(build_dir, ignore_errors=True)

# Remove egg-info directories
for egg_info in PYWHISPERCPP_SRC_DIR.glob('*.egg-info'):
if egg_info.is_dir():
shutil.rmtree(egg_info, ignore_errors=True)

# Remove CMake cache files (these can cache Python version)
for cmake_cache in PYWHISPERCPP_SRC_DIR.rglob('CMakeCache.txt'):
cmake_cache.unlink(missing_ok=True)
for cmake_files in PYWHISPERCPP_SRC_DIR.rglob('CMakeFiles'):
if cmake_files.is_dir():
shutil.rmtree(cmake_files, ignore_errors=True)

# Clean __pycache__ directories
for pycache in PYWHISPERCPP_SRC_DIR.rglob('__pycache__'):
if pycache.is_dir():
shutil.rmtree(pycache, ignore_errors=True)

# Clone or update pywhispercpp sources
if not PYWHISPERCPP_SRC_DIR.exists() or not (PYWHISPERCPP_SRC_DIR / '.git').exists():
log_info(f"Cloning pywhispercpp sources (v{PYWHISPERCPP_VERSION}) → {PYWHISPERCPP_SRC_DIR}")
PYWHISPERCPP_SRC_DIR.parent.mkdir(parents=True, exist_ok=True)
verbosity = OutputController.get_verbosity()
verbose = verbosity.value >= VerbosityLevel.VERBOSE.value
run_command([
'git', 'clone', '--recurse-submodules',
'https://github.com/Absadiki/pywhispercpp.git',
Expand All @@ -2032,17 +2024,48 @@ def install_pywhispercpp_cuda(pip_bin: Path) -> bool:
], check=True, verbose=verbose)
else:
log_info(f"Updating pywhispercpp sources to v{PYWHISPERCPP_VERSION} in {PYWHISPERCPP_SRC_DIR}")
verbosity = OutputController.get_verbosity()
verbose = verbosity.value >= VerbosityLevel.VERBOSE.value
try:
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'fetch', '--tags'],
check=False, verbose=verbose)
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'checkout', PYWHISPERCPP_PINNED_COMMIT],
check=False, verbose=verbose)
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'submodule', 'update', '--init', '--recursive'],
check=False, verbose=verbose)
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'fetch', '--tags'],
check=False, verbose=verbose)
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'checkout', PYWHISPERCPP_PINNED_COMMIT],
check=False, verbose=verbose)
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'submodule', 'update', '--init', '--recursive'],
check=False, verbose=verbose)
except Exception as e:
log_warning(f"Could not update pywhispercpp repository to v{PYWHISPERCPP_VERSION}: {e}")

# The fetch/checkout above are best-effort; confirm the pin actually landed rather
# than building the previous commit and failing later with an unrelated-looking error.
head = run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'rev-parse', 'HEAD'],
check=False, capture_output=True)
current = (head.stdout or '').strip() if head.returncode == 0 else ''
if current != PYWHISPERCPP_PINNED_COMMIT:
log_error(
f"pywhispercpp sources are at {current or 'an unknown commit'}, not the pinned "
f"{PYWHISPERCPP_PINNED_COMMIT} (v{PYWHISPERCPP_VERSION})"
)
log_error(f"Remove {PYWHISPERCPP_SRC_DIR} and re-run to get a clean checkout")
return False

return True


def install_pywhispercpp_cuda(pip_bin: Path) -> bool:
"""Install pywhispercpp with CUDA support"""
log_info("Installing pywhispercpp with CUDA support...")

# Try pre-built wheel first (much faster than source build)
wheel_path = download_pywhispercpp_wheel() # Auto-detects CUDA version
if wheel_path:
if install_pywhispercpp_from_wheel(pip_bin, wheel_path):
return True
log_warning("Pre-built wheel failed, falling back to source build...")

install_system_dependencies()
log_info("Building from source (this may take several minutes)...")

if not _prepare_pywhispercpp_sources():
return False

# Build with CUDA support
log_info("Building pywhispercpp with CUDA (ggml CUDA) via pip - may take several minutes")
Expand Down Expand Up @@ -2087,70 +2110,8 @@ def install_pywhispercpp_rocm(pip_bin: Path) -> Tuple[bool, bool]:
log_info("Installing pywhispercpp with ROCm support...")
install_system_dependencies()

# Clean build artifacts if they exist (to avoid Python version mismatches)
if PYWHISPERCPP_SRC_DIR.exists():
log_info("Cleaning existing build artifacts...")
import shutil
# Remove common build directories
build_dirs = [
PYWHISPERCPP_SRC_DIR / 'build',
PYWHISPERCPP_SRC_DIR / 'dist',
PYWHISPERCPP_SRC_DIR / 'whisper.cpp' / 'build',
PYWHISPERCPP_SRC_DIR / 'whisper.cpp' / 'ggml' / 'build',
]
for build_dir in build_dirs:
if build_dir.exists():
shutil.rmtree(build_dir, ignore_errors=True)

# Remove egg-info directories
for egg_info in PYWHISPERCPP_SRC_DIR.glob('*.egg-info'):
if egg_info.is_dir():
shutil.rmtree(egg_info, ignore_errors=True)

# Remove CMake cache files (these can cache Python version)
for cmake_cache in PYWHISPERCPP_SRC_DIR.rglob('CMakeCache.txt'):
cmake_cache.unlink(missing_ok=True)
for cmake_files in PYWHISPERCPP_SRC_DIR.rglob('CMakeFiles'):
if cmake_files.is_dir():
shutil.rmtree(cmake_files, ignore_errors=True)

# Clean __pycache__ directories
for pycache in PYWHISPERCPP_SRC_DIR.rglob('__pycache__'):
if pycache.is_dir():
shutil.rmtree(pycache, ignore_errors=True)

# Clone or update pywhispercpp sources
if not PYWHISPERCPP_SRC_DIR.exists() or not (PYWHISPERCPP_SRC_DIR / '.git').exists():
log_info(f"Cloning pywhispercpp sources (v{PYWHISPERCPP_VERSION}) → {PYWHISPERCPP_SRC_DIR}")
PYWHISPERCPP_SRC_DIR.parent.mkdir(parents=True, exist_ok=True)
verbosity = OutputController.get_verbosity()
verbose = verbosity.value >= VerbosityLevel.VERBOSE.value
run_command([
'git', 'clone', '--recurse-submodules',
'https://github.com/Absadiki/pywhispercpp.git',
str(PYWHISPERCPP_SRC_DIR)
], check=True, verbose=verbose)
run_command([
'git', '-C', str(PYWHISPERCPP_SRC_DIR),
'checkout', PYWHISPERCPP_PINNED_COMMIT
], check=True, verbose=verbose)
run_command([
'git', '-C', str(PYWHISPERCPP_SRC_DIR),
'submodule', 'update', '--init', '--recursive'
], check=True, verbose=verbose)
else:
log_info(f"Updating pywhispercpp sources to v{PYWHISPERCPP_VERSION} in {PYWHISPERCPP_SRC_DIR}")
verbosity = OutputController.get_verbosity()
verbose = verbosity.value >= VerbosityLevel.VERBOSE.value
try:
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'fetch', '--tags'],
check=False, verbose=verbose)
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'checkout', PYWHISPERCPP_PINNED_COMMIT],
check=False, verbose=verbose)
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'submodule', 'update', '--init', '--recursive'],
check=False, verbose=verbose)
except Exception as e:
log_warning(f"Could not update pywhispercpp repository to v{PYWHISPERCPP_VERSION}: {e}")
if not _prepare_pywhispercpp_sources():
return False, True

# Set up ROCm environment
rocm_path = os.environ.get('ROCM_PATH', '/opt/rocm')
Expand Down Expand Up @@ -2210,70 +2171,8 @@ def install_pywhispercpp_vulkan(pip_bin: Path) -> bool:
log_info("Installing pywhispercpp with Vulkan support...")
install_system_dependencies()

# Clean build artifacts if they exist (to avoid Python version mismatches)
if PYWHISPERCPP_SRC_DIR.exists():
log_info("Cleaning existing build artifacts...")
import shutil
# Remove common build directories
build_dirs = [
PYWHISPERCPP_SRC_DIR / 'build',
PYWHISPERCPP_SRC_DIR / 'dist',
PYWHISPERCPP_SRC_DIR / 'whisper.cpp' / 'build',
PYWHISPERCPP_SRC_DIR / 'whisper.cpp' / 'ggml' / 'build',
]
for build_dir in build_dirs:
if build_dir.exists():
shutil.rmtree(build_dir, ignore_errors=True)

# Remove egg-info directories
for egg_info in PYWHISPERCPP_SRC_DIR.glob('*.egg-info'):
if egg_info.is_dir():
shutil.rmtree(egg_info, ignore_errors=True)

# Remove CMake cache files (these can cache Python version)
for cmake_cache in PYWHISPERCPP_SRC_DIR.rglob('CMakeCache.txt'):
cmake_cache.unlink(missing_ok=True)
for cmake_files in PYWHISPERCPP_SRC_DIR.rglob('CMakeFiles'):
if cmake_files.is_dir():
shutil.rmtree(cmake_files, ignore_errors=True)

# Clean __pycache__ directories
for pycache in PYWHISPERCPP_SRC_DIR.rglob('__pycache__'):
if pycache.is_dir():
shutil.rmtree(pycache, ignore_errors=True)

# Clone or update pywhispercpp sources
if not PYWHISPERCPP_SRC_DIR.exists() or not (PYWHISPERCPP_SRC_DIR / '.git').exists():
log_info(f"Cloning pywhispercpp sources (v{PYWHISPERCPP_VERSION}) → {PYWHISPERCPP_SRC_DIR}")
PYWHISPERCPP_SRC_DIR.parent.mkdir(parents=True, exist_ok=True)
verbosity = OutputController.get_verbosity()
verbose = verbosity.value >= VerbosityLevel.VERBOSE.value
run_command([
'git', 'clone', '--recurse-submodules',
'https://github.com/Absadiki/pywhispercpp.git',
str(PYWHISPERCPP_SRC_DIR)
], check=True, verbose=verbose)
run_command([
'git', '-C', str(PYWHISPERCPP_SRC_DIR),
'checkout', PYWHISPERCPP_PINNED_COMMIT
], check=True, verbose=verbose)
run_command([
'git', '-C', str(PYWHISPERCPP_SRC_DIR),
'submodule', 'update', '--init', '--recursive'
], check=True, verbose=verbose)
else:
log_info(f"Updating pywhispercpp sources to v{PYWHISPERCPP_VERSION} in {PYWHISPERCPP_SRC_DIR}")
verbosity = OutputController.get_verbosity()
verbose = verbosity.value >= VerbosityLevel.VERBOSE.value
try:
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'fetch', '--tags'],
check=False, verbose=verbose)
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'checkout', PYWHISPERCPP_PINNED_COMMIT],
check=False, verbose=verbose)
run_command(['git', '-C', str(PYWHISPERCPP_SRC_DIR), 'submodule', 'update', '--init', '--recursive'],
check=False, verbose=verbose)
except Exception as e:
log_warning(f"Could not update pywhispercpp repository to v{PYWHISPERCPP_VERSION}: {e}")
if not _prepare_pywhispercpp_sources():
return False

# Set up Vulkan environment
# Start with mise-free environment if mise is active, otherwise use current environment
Expand Down
2 changes: 1 addition & 1 deletion requirements-pywhispercpp.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
-r requirements.txt
pywhispercpp==1.5.0
pywhispercpp==1.5.1
39 changes: 39 additions & 0 deletions tests/test_wheel_pipeline_and_defaults.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import subprocess
import sys
import tempfile
import types
Expand Down Expand Up @@ -183,5 +184,43 @@ def test_stale_pywhispercpp_typeerror_falls_back(self):
self.assertNotIn("vad", self._FakeModel.last_kwargs)


class PywhispercppSourcePinTests(unittest.TestCase):
"""The clone/update is best-effort (check=False), so the pin assertion is the
only thing standing between a failed fetch and a silent build of the old commit."""

def _run(self, src_dir, head_result):
def fake_run_command(cmd, **kwargs):
if "rev-parse" in cmd:
return head_result
return subprocess.CompletedProcess(cmd, 0, "", "")

with mock.patch.object(backend_installer, "PYWHISPERCPP_SRC_DIR", src_dir), \
mock.patch.object(backend_installer, "run_command", side_effect=fake_run_command):
return backend_installer._prepare_pywhispercpp_sources()

def test_head_on_pin_succeeds(self):
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "pywhispercpp-src"
(src / ".git").mkdir(parents=True)
head = subprocess.CompletedProcess(
[], 0, backend_installer.PYWHISPERCPP_PINNED_COMMIT + "\n", ""
)
self.assertTrue(self._run(src, head))

def test_stale_head_fails_instead_of_building(self):
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "pywhispercpp-src"
(src / ".git").mkdir(parents=True)
stale = subprocess.CompletedProcess([], 0, "294e1e15f1fa3991aaa8db5f5e9afb97ade5ba5f\n", "")
self.assertFalse(self._run(src, stale))

def test_unreadable_head_fails(self):
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "pywhispercpp-src"
(src / ".git").mkdir(parents=True)
broken = subprocess.CompletedProcess([], 128, "", "not a git repository")
self.assertFalse(self._run(src, broken))


if __name__ == "__main__":
unittest.main()