From c844781d1e06086017e40708ac3b03d39c51ef72 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sun, 13 Sep 2026 15:44:43 -0500 Subject: [PATCH] fix(backends): explain why an explicitly requested device cannot be used --device nvidia on a machine without one reported "no usable backend remains", which says nothing about the cause and repeated the same message once per URL. Asking for a device that cannot run is a configuration mistake, so it now fails once, fatally, naming the reason: "nvidia/cuda: no nvidia hardware detected". Found by end-to-end testing; the unit tests only covered the runtime-failure path, where TranscribeError remains correct. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 ++++ src/ccn_transcribe/backends/registry.py | 21 +++++++++++++++++++ tests/test_backends_registry.py | 28 +++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/.gitignore b/.gitignore index bba4c00..20049d7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ out/ .coverage htmlcov/ .pytest_cache/ + +# Tool caches +.mypy_cache/ +.ruff_cache/ diff --git a/src/ccn_transcribe/backends/registry.py b/src/ccn_transcribe/backends/registry.py index 970f0aa..4ca615d 100644 --- a/src/ccn_transcribe/backends/registry.py +++ b/src/ccn_transcribe/backends/registry.py @@ -55,6 +55,20 @@ def _select_requested( return matches +def explain_unavailable(requested: str, chain: tuple[type[Backend], ...] | None = None) -> str: + """Why an explicitly requested backend cannot be used.""" + chain = chain if chain is not None else DEFAULT_CHAIN + reasons: list[str] = [] + for backend in _select_requested(requested, chain): + label = key(backend) + if not backend.probe_hardware(): + reasons.append(f"{label}: no {backend.name} hardware detected") + continue + status = backend.probe_toolchain() + reasons.append(f"{label}: {status.reason or 'unavailable'}") + return "; ".join(reasons) + + def candidates( requested: str | None = None, chain: tuple[type[Backend], ...] | None = None ) -> Iterator[type[Backend]]: @@ -149,6 +163,13 @@ def transcribe_with_fallback( log.warning("backend %s failed (%s); demoting it and falling back", label, exc) last = exc + if explicit and requested is not None and last is None: + # Asking for a backend that cannot run is a configuration mistake, not a + # per-job failure; say why once and stop rather than repeating it. + raise errors.ConfigError( + f"--device {requested} was requested but cannot be used", + hint=explain_unavailable(requested, chain), + ) raise errors.TranscribeError( "no usable backend remains", hint=str(last) if last else "Run `ccn-transcribe doctor` to see what was detected.", diff --git a/tests/test_backends_registry.py b/tests/test_backends_registry.py index 55c413d..33d03db 100644 --- a/tests/test_backends_registry.py +++ b/tests/test_backends_registry.py @@ -225,3 +225,31 @@ class TestRealChain: sys.modules.pop(module, None) list(registry.candidates()) assert not {"torch", "faster_whisper", "ctranslate2"} & set(sys.modules) + + +class TestExplicitDeviceDiagnostics: + def test_requesting_absent_hardware_is_a_config_error(self) -> None: + # A device that cannot run is a configuration mistake, not a per-job + # failure to repeat once per URL. + a = make_backend("a", hardware=False) + with pytest.raises(errors.ConfigError, match="cannot be used"): + registry.transcribe_with_fallback(PCM, REQ, requested="a", chain=(a,)) + + def test_the_hint_says_the_hardware_is_absent(self) -> None: + a = make_backend("a", hardware=False) + try: + registry.transcribe_with_fallback(PCM, REQ, requested="a", chain=(a,)) + except errors.ConfigError as exc: + assert "no a hardware detected" in (exc.hint or "") + + def test_the_hint_relays_a_toolchain_reason(self) -> None: + a = make_backend("a", toolchain=ToolchainStatus(ok=False, reason="not implemented")) + assert "not implemented" in registry.explain_unavailable("a", (a,)) + + def test_a_runtime_failure_still_reports_as_a_transcribe_error(self) -> None: + a = make_backend("a", fail_on_transcribe=True) + with pytest.raises(errors.TranscribeError): + registry.transcribe_with_fallback(PCM, REQ, requested="a", chain=(a,)) + + def test_real_nvidia_request_explains_the_absence(self) -> None: + assert "no nvidia hardware" in registry.explain_unavailable("nvidia")