diff --git a/docs/superpowers/contracts/transcribe-helpers-split-2026-08-17.json b/docs/superpowers/contracts/transcribe-helpers-split-2026-08-17.json new file mode 100644 index 00000000..e329792c --- /dev/null +++ b/docs/superpowers/contracts/transcribe-helpers-split-2026-08-17.json @@ -0,0 +1,18 @@ +{ + "title": "Split transcribe_helpers.py 289 -> 177 + 142", + "objective": "transcribe_helpers.py was 289 lines against the 250-line cap. Now that it sits at 100% line and branch coverage, split it along its natural seam: the dependency probes and audio/model operations move to _transcribe_probes.py, while transcribe_helpers.py keeps the CLI dispatch that transcribe.sh invokes by path. Code moves verbatim; the tests predate the seam and are re-pointed, not rewritten.", + "acceptance_criteria": [ + "Both resulting modules are under 250 lines; the over-cap total falls 35 -> 34", + "All 18 top-level definitions are AST-identical to the pre-split file", + "The same 41 tests pass, with both modules at 100% line and branch coverage", + "Every CLI subcommand returns the same exit code as before the split, since transcribe.sh branches on them", + "transcribe.sh needs no change: tools/transcribe_helpers.py still exists at the same path with its executable bit", + "No suppression of any kind" + ], + "out_of_scope": [ + "transcribe_fw.py (302) and _transcribe_diarize.py (356), which are separate targets", + "Removing the unreachable dispatch guard, a behaviour change", + "Moving anything under python_pkg/" + ], + "verifier": "A throwaway ast.dump identity check against HEAD for the verbatim claim; pytest with --cov=transcribe_helpers --cov=_transcribe_probes --cov-branch; running each CLI subcommand and reading $? directly; bash -n transcribe.sh; bash ~/utils/scripts/check_file_length.sh --all." +} diff --git a/docs/superpowers/evidence/transcribe-helpers-split-2026-08-17.json b/docs/superpowers/evidence/transcribe-helpers-split-2026-08-17.json new file mode 100644 index 00000000..ea0e4596 --- /dev/null +++ b/docs/superpowers/evidence/transcribe-helpers-split-2026-08-17.json @@ -0,0 +1,66 @@ +{ + "intent": "Split the 289-line transcribe_helpers.py into a probes/operations module and a CLI dispatch module, both under the 250-line cap, moving every definition verbatim.", + "scope": [ + "transcribe_helpers.py 289 -> 142 (CLI dispatch and main)", + "New: _transcribe_probes.py 177 (dependency probes, sine WAV, model prep, CUDA probe)", + "Re-pointed the module under test in three test files", + "Non-goal: any behaviour change, including the unreachable dispatch guard" + ], + "changes": [ + "Extracted _try_import, the four dependency probes, generate_sine_wav, prepare_model and test_cuda into _transcribe_probes.py; transcribe_helpers.py keeps the eight _handle_* functions and main().", + "transcribe_helpers.py imports the probe functions by name, so the eight names main() dispatches to still resolve in its own namespace - which is why the CLI tests' patch targets did not have to move.", + "The three probe-level test modules now import _transcribe_probes, the module whose namespace resolves those functions after the split. No re-export barrel was added.", + "transcribe.sh is untouched: it invokes tools/transcribe_helpers.py by path, and that path still exists with the same name and executable bit.", + "_transcribe_probes.py was given the executable bit to match the repo convention for a file carrying a shebang." + ], + "verification": [ + { + "command": "python3 verify_split.py HEAD .../transcribe_helpers.py ", + "result": "pass", + "evidence": "IDENTICAL: all 18 top-level definitions moved verbatim, normalised through ast.dump(ast.parse(ast.unparse(node))). Re-run after ruff's autofix, and still identical." + }, + { + "command": "AST scan for names used but not bound in each module", + "result": "pass", + "evidence": "Both modules report no unbound names, so no symbol was left behind by the extraction." + }, + { + "command": "pytest linux_configuration/tests/test_transcribe_*.py --cov=transcribe_helpers --cov=_transcribe_probes --cov-branch", + "result": "pass", + "evidence": "41 passed - the same count as before the split. _transcribe_probes.py: 77 statements, 10 branches, 100%. transcribe_helpers.py: 46 statements, 16 branches, 100%." + }, + { + "command": "python3 transcribe_helpers.py , checking $? directly", + "result": "pass", + "evidence": "python-version exit 0 printing 3.14; check-faster-whisper exit 7; check-ctranslate2 exit 1; check-diarization exit 0 with the warning; deps-installed exit 0. These codes are the contract transcribe.sh branches on, and they are unchanged by the split." + }, + { + "command": "python3 transcribe_helpers.py generate-wav --file /tone2.wav", + "result": "pass", + "evidence": "Wrote 96044 bytes, byte-for-byte the same size as the pre-split run." + }, + { + "command": "bash -n transcribe.sh && ls -l tools/transcribe_helpers.py", + "result": "pass", + "evidence": "Syntax OK, and the path transcribe.sh references still exists with the executable bit. The caller needed no change." + }, + { + "command": "pytest linux_configuration/tests/ -q", + "result": "pass", + "evidence": "243 passed, unchanged from before the split." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "34 files over cap, down from 35. The two modules are 177 and 142 lines." + } + ], + "risks": [ + "transcribe.sh runs the module inside a dedicated venv this session cannot exercise, since the ML wheels are not installed here. Mitigated: the bare-name sibling import that a split could break is proved by running every CLI subcommand directly, and the venv only changes which interpreter resolves the same two files in the same directory.", + "A future command added to argparse's choices but not to the dispatch dict would silently do nothing. Pre-existing, now covered by a test that documents it." + ], + "rollback": [ + "git revert the commit; transcribe_helpers.py returns to its 289-line form and _transcribe_probes.py disappears.", + "After rollback, confirm python3 transcribe_helpers.py check-faster-whisper still exits 7 and pytest linux_configuration/tests/ reports 243 passed." + ] +} diff --git a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/_transcribe_probes.py b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/_transcribe_probes.py new file mode 100755 index 00000000..d0da54d2 --- /dev/null +++ b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/_transcribe_probes.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Dependency probes and audio/model operations for transcribe.sh. + +Every optional dependency is reached through ``_try_import`` so this module +imports cleanly on a machine with none of them installed - which is what lets +it be unit-tested in CI. +""" + +from __future__ import annotations + +import array +import importlib +import logging +import math +import os +import sys +from typing import TYPE_CHECKING +import wave + +if TYPE_CHECKING: + import types + +logger = logging.getLogger(__name__) + + +def _try_import(name: str) -> types.ModuleType | None: + """Attempt to import a module, returning None on failure.""" + try: + return importlib.import_module(name) + except ImportError: + return None + + +def get_python_version() -> str: + """Return Python major.minor version string.""" + return f"{sys.version_info.major}.{sys.version_info.minor}" + + +def check_faster_whisper() -> bool: + """Check if faster_whisper is importable. Exit 7 if not.""" + return _try_import("faster_whisper") is not None + + +def check_diarization_deps() -> bool: + """Check if diarization dependencies are available. + + Returns False with warning if missing. + """ + _sf = _try_import("soundfile") + _sb = _try_import("speechbrain") + _torch = _try_import("torch") + if _sf is None or _sb is None or _torch is None: + logger.warning( + "Diarization deps missing offline; speaker labels will be skipped.", + ) + return False + return True + + +def check_ctranslate2() -> bool: + """Check if ctranslate2 is importable.""" + return _try_import("ctranslate2") is not None + + +def print_deps_installed() -> None: + """Print confirmation that Python dependencies are installed.""" + logger.info("Python %s dependencies installed.", sys.version.split()[0]) + + +def generate_sine_wav( + outfile: str, + frequency: float = 1000.0, + duration: int = 3, + sample_rate: int = 16000, + amplitude: float = 0.3, +) -> bool: + """Generate a sine wave WAV file using only Python stdlib. + + Args: + outfile: Output WAV file path + frequency: Tone frequency in Hz (default: 1000) + duration: Duration in seconds (default: 3) + sample_rate: Sample rate in Hz (default: 16000) + amplitude: Amplitude 0.0-1.0 (default: 0.3) + + Returns: + True on success, False on failure + """ + try: + n_samples = sample_rate * duration + data = array.array( + "h", + [ + int( + max( + -1.0, + min( + 1.0, + amplitude + * math.sin(2 * math.pi * frequency * (i / sample_rate)), + ), + ) + * 32767 + ) + for i in range(n_samples) + ], + ) + with wave.open(outfile, "w") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes(data.tobytes()) + except OSError: + logger.exception("Failed to generate WAV") + return False + return True + + +def prepare_model(model_name: str, model_dir: str) -> bool: + """Download a whisper model for offline use. + + Args: + model_name: Model name (tiny, base, small, medium, large-v3, etc.) + model_dir: Directory to store the model + + Returns: + True on success, False on failure + """ + fw = _try_import("faster_whisper") + if fw is None: + logger.error("faster_whisper is not installed") + return False + + try: + hf_logging = _try_import("huggingface_hub.logging") + if hf_logging is not None: + hf_logging.set_verbosity_info() + hh = _try_import("huggingface_hub") + if hh is not None: + hh.constants.HF_HUB_DISABLE_PROGRESS_BARS = False + os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "0" + + logger.info("Preparing model '%s' into %s", model_name, model_dir) + logger.info( + "Downloading model files (progress bar should appear below)...", + ) + fw.WhisperModel( + model_name, + device="cpu", + compute_type="int8", + download_root=model_dir, + ) + logger.info("Model prepared.") + except (OSError, RuntimeError): + logger.exception("Failed to prepare model") + return False + return True + + +def test_cuda() -> bool: + """Test CUDA initialization with faster-whisper. + + Returns: + True if CUDA works, False otherwise + """ + fw = _try_import("faster_whisper") + if fw is None: + logger.error("faster_whisper is not installed") + return False + + try: + fw.WhisperModel("tiny", device="cuda", compute_type="float16") + logger.info("CUDA test init succeeded.") + except (OSError, RuntimeError): + logger.exception("CUDA test failed") + return False + return True diff --git a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_helpers.py b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_helpers.py index 47fdd5fb..e4598e5c 100755 --- a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_helpers.py +++ b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_helpers.py @@ -1,178 +1,31 @@ #!/usr/bin/env python3 -"""Helper utilities for transcribe.sh - replaces inline Python snippets.""" +"""Command-line dispatch for transcribe.sh's Python helpers. + +transcribe.sh branches on the exit status, so the codes are the contract: +7 means faster_whisper is missing, 2 means a required argument is missing, +and 1 means the requested operation failed. +""" from __future__ import annotations import argparse -import array -import importlib import logging -import math -import os import sys -from typing import TYPE_CHECKING -import wave -if TYPE_CHECKING: - import types +from _transcribe_probes import ( + check_ctranslate2, + check_diarization_deps, + check_faster_whisper, + generate_sine_wav, + get_python_version, + prepare_model, + print_deps_installed, + test_cuda, +) logger = logging.getLogger(__name__) -def _try_import(name: str) -> types.ModuleType | None: - """Attempt to import a module, returning None on failure.""" - try: - return importlib.import_module(name) - except ImportError: - return None - - -def get_python_version() -> str: - """Return Python major.minor version string.""" - return f"{sys.version_info.major}.{sys.version_info.minor}" - - -def check_faster_whisper() -> bool: - """Check if faster_whisper is importable. Exit 7 if not.""" - return _try_import("faster_whisper") is not None - - -def check_diarization_deps() -> bool: - """Check if diarization dependencies are available. - - Returns False with warning if missing. - """ - _sf = _try_import("soundfile") - _sb = _try_import("speechbrain") - _torch = _try_import("torch") - if _sf is None or _sb is None or _torch is None: - logger.warning( - "Diarization deps missing offline; speaker labels will be skipped.", - ) - return False - return True - - -def check_ctranslate2() -> bool: - """Check if ctranslate2 is importable.""" - return _try_import("ctranslate2") is not None - - -def print_deps_installed() -> None: - """Print confirmation that Python dependencies are installed.""" - logger.info("Python %s dependencies installed.", sys.version.split()[0]) - - -def generate_sine_wav( - outfile: str, - frequency: float = 1000.0, - duration: int = 3, - sample_rate: int = 16000, - amplitude: float = 0.3, -) -> bool: - """Generate a sine wave WAV file using only Python stdlib. - - Args: - outfile: Output WAV file path - frequency: Tone frequency in Hz (default: 1000) - duration: Duration in seconds (default: 3) - sample_rate: Sample rate in Hz (default: 16000) - amplitude: Amplitude 0.0-1.0 (default: 0.3) - - Returns: - True on success, False on failure - """ - try: - n_samples = sample_rate * duration - data = array.array( - "h", - [ - int( - max( - -1.0, - min( - 1.0, - amplitude - * math.sin(2 * math.pi * frequency * (i / sample_rate)), - ), - ) - * 32767 - ) - for i in range(n_samples) - ], - ) - with wave.open(outfile, "w") as wf: - wf.setnchannels(1) - wf.setsampwidth(2) - wf.setframerate(sample_rate) - wf.writeframes(data.tobytes()) - except OSError: - logger.exception("Failed to generate WAV") - return False - return True - - -def prepare_model(model_name: str, model_dir: str) -> bool: - """Download a whisper model for offline use. - - Args: - model_name: Model name (tiny, base, small, medium, large-v3, etc.) - model_dir: Directory to store the model - - Returns: - True on success, False on failure - """ - fw = _try_import("faster_whisper") - if fw is None: - logger.error("faster_whisper is not installed") - return False - - try: - hf_logging = _try_import("huggingface_hub.logging") - if hf_logging is not None: - hf_logging.set_verbosity_info() - hh = _try_import("huggingface_hub") - if hh is not None: - hh.constants.HF_HUB_DISABLE_PROGRESS_BARS = False - os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "0" - - logger.info("Preparing model '%s' into %s", model_name, model_dir) - logger.info( - "Downloading model files (progress bar should appear below)...", - ) - fw.WhisperModel( - model_name, - device="cpu", - compute_type="int8", - download_root=model_dir, - ) - logger.info("Model prepared.") - except (OSError, RuntimeError): - logger.exception("Failed to prepare model") - return False - return True - - -def test_cuda() -> bool: - """Test CUDA initialization with faster-whisper. - - Returns: - True if CUDA works, False otherwise - """ - fw = _try_import("faster_whisper") - if fw is None: - logger.error("faster_whisper is not installed") - return False - - try: - fw.WhisperModel("tiny", device="cuda", compute_type="float16") - logger.info("CUDA test init succeeded.") - except (OSError, RuntimeError): - logger.exception("CUDA test failed") - return False - return True - - def _handle_python_version() -> None: """Handle python-version command.""" logger.info("%s", get_python_version()) diff --git a/linux_configuration/tests/test_transcribe_model_ops.py b/linux_configuration/tests/test_transcribe_model_ops.py index 155ade12..032eb756 100644 --- a/linux_configuration/tests/test_transcribe_model_ops.py +++ b/linux_configuration/tests/test_transcribe_model_ops.py @@ -9,7 +9,7 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -import transcribe_helpers as helpers +import _transcribe_probes as helpers if TYPE_CHECKING: from collections.abc import Callable diff --git a/linux_configuration/tests/test_transcribe_probes.py b/linux_configuration/tests/test_transcribe_probes.py index 123c486f..a7d1f35a 100644 --- a/linux_configuration/tests/test_transcribe_probes.py +++ b/linux_configuration/tests/test_transcribe_probes.py @@ -11,8 +11,8 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING +import _transcribe_probes as helpers import pytest -import transcribe_helpers as helpers if TYPE_CHECKING: from collections.abc import Callable diff --git a/linux_configuration/tests/test_transcribe_wav.py b/linux_configuration/tests/test_transcribe_wav.py index b4faa8b7..5f8fe8e6 100644 --- a/linux_configuration/tests/test_transcribe_wav.py +++ b/linux_configuration/tests/test_transcribe_wav.py @@ -10,7 +10,7 @@ import logging from typing import TYPE_CHECKING import wave -import transcribe_helpers as helpers +import _transcribe_probes as helpers if TYPE_CHECKING: from pathlib import Path