From a72ac1187c3e77198e6fd7384a75698c9e51298f Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Wed, 18 Mar 2026 19:48:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B8=20graceful=20Ctrl+C=20(=D1=88=D0=B0=D0=B3=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - пользователь видел raw traceback при ошибках, не было корректной обработки Ctrl+C - Что: - graceful Ctrl+C: перехват KeyboardInterrupt → «Прервано пользователем» + exit 130 - пользовательские ошибки (файл не найден, пустой) — чистое сообщение без traceback - неожиданные ошибки — traceback только с --verbose, иначе подсказка - Windows CUDA диагностика интегрирована в общий обработчик ошибок - логика вынесена в _run(), обработка ошибок — в main() - Проверка: - uv run pytest -v — 60 passed, 1 skipped Co-Authored-By: Claude Opus 4.6 --- docs/plan.md | 8 ++-- src/local_transcriber/cli.py | 73 ++++++++++++++++++++++++------------ tests/test_cli.py | 70 ++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 27 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index 8137b9e..d72de60 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -261,10 +261,10 @@ > PRD-ссылки: 6 (риски) -- [ ] Graceful Ctrl+C: перехват `KeyboardInterrupt` в cli.py → `console.print("Прервано пользователем", style="yellow")` + `raise SystemExit(130)` -- [ ] Красивые ошибки: обернуть main в try/except, для пользовательских ошибок (файл не найден, ffmpeg, OOM) — вывод через rich без traceback; для неожиданных — traceback только с `--verbose` -- [ ] `--verbose` режим: реализуется через `on_segment` callback в `transcribe()` (уже заложен в шаге 3) — печатать каждый сегмент в stderr по мере поступления -- [ ] Проверка: неподдерживаемое расширение → warning, но попытка продолжить +- [x] Graceful Ctrl+C: перехват `KeyboardInterrupt` в cli.py → `console.print("Прервано пользователем", style="yellow")` + `raise SystemExit(130)` +- [x] Красивые ошибки: обернуть main в try/except, для пользовательских ошибок (файл не найден, ffmpeg, OOM) — вывод через rich без traceback; для неожиданных — traceback только с `--verbose` +- [x] `--verbose` режим: реализуется через `on_segment` callback в `transcribe()` (уже заложен в шаге 3) — печатать каждый сегмент в stderr по мере поступления +- [x] Проверка: неподдерживаемое расширение → warning, но попытка продолжить **Критерий готовности**: ручной прогон edge cases — несуществующий файл, .txt файл, Ctrl+C во время работы. diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index 5d617b3..8a0bf46 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -23,6 +23,45 @@ def main( device: str = typer.Option("auto", "--device", "-d", help="Устройство (auto|cpu|cuda)"), compute_type: str = typer.Option("int8", "--compute-type", help="Тип вычислений"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), +) -> None: + try: + _run(file, model, language, output, device, compute_type, verbose) + except KeyboardInterrupt: + console.print("\nПрервано пользователем.", style="yellow") + raise SystemExit(130) + except SystemExit: + raise + except (FileNotFoundError, ValueError) as exc: + console.print(f"Ошибка: {exc}", style="red bold") + raise SystemExit(1) + except Exception as exc: + if _is_cuda_error(exc) and sys.platform == "win32": + console.print( + "GPU на Windows требует CUDA toolkit (включает cuBLAS).\n" + "Установите одним из способов:\n" + " choco install cuda\n" + " winget install -e --id Nvidia.CUDA\n" + "После установки перезапустите терминал.", + style="yellow", + ) + if verbose: + console.print_exception() + else: + console.print(f"Ошибка: {exc}", style="red bold") + console.print( + "Запустите с --verbose для полного traceback.", style="dim" + ) + raise SystemExit(1) + + +def _run( + file: Path, + model: str, + language: str, + output: Path | None, + device: str, + compute_type: str, + verbose: bool, ) -> None: start = time.monotonic() @@ -41,29 +80,17 @@ def main( def on_segment(seg: Segment) -> None: console.print(f" [{seg.start:.2f}s] {seg.text.strip()}") - try: - with Status("Подготавливаю запуск...", console=console) as status: - result = transcribe( - file_path=validated_file, - model_name=model_path, - device=resolved_device, - compute_type=compute_type, - language=language if language != "auto" else None, - on_segment=on_segment if verbose else None, - on_status=status.update, - strict_device=strict, - ) - except (RuntimeError, ValueError) as exc: - if _is_cuda_error(exc) and sys.platform == "win32": - console.print( - "GPU на Windows требует CUDA toolkit (включает cuBLAS).\n" - "Установите одним из способов:\n" - " choco install cuda\n" - " winget install -e --id Nvidia.CUDA\n" - "После установки перезапустите терминал.", - style="yellow", - ) - raise + with Status("Подготавливаю запуск...", console=console) as status: + result = transcribe( + file_path=validated_file, + model_name=model_path, + device=resolved_device, + compute_type=compute_type, + language=language if language != "auto" else None, + on_segment=on_segment if verbose else None, + on_status=status.update, + strict_device=strict, + ) if result.device_used != resolved_device: if requested_device == "auto": diff --git a/tests/test_cli.py b/tests/test_cli.py index cc75201..aac8747 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -333,3 +333,73 @@ def test_cli_strict_device_passed_to_transcribe(tmp_path): runner.invoke(app, [str(audio)]) assert mock_transcribe.call_args[1]["strict_device"] is False + + +def test_cli_keyboard_interrupt(tmp_path): + """Ctrl+C → exit code 130, 'Прервано пользователем' in output.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + with ( + patch("local_transcriber.cli.check_ffmpeg"), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"), + patch("local_transcriber.cli.transcribe", side_effect=KeyboardInterrupt), + patch("local_transcriber.cli.write_transcript"), + ): + out = runner.invoke(app, [str(audio)]) + + assert out.exit_code == 130 + assert "Прервано пользователем" in out.output + + +def test_cli_user_error_no_traceback(tmp_path): + """FileNotFoundError → clean message, no traceback.""" + audio = tmp_path / "missing.mp3" + + with patch("local_transcriber.cli.check_ffmpeg"): + out = runner.invoke(app, [str(audio)]) + + assert out.exit_code == 1 + assert "Ошибка" in out.output + assert "Traceback" not in out.output + + +def test_cli_unexpected_error_verbose_traceback(tmp_path): + """Unexpected error with --verbose → traceback shown.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + with ( + patch("local_transcriber.cli.check_ffmpeg"), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"), + patch("local_transcriber.cli.transcribe", side_effect=RuntimeError("unexpected boom")), + patch("local_transcriber.cli.write_transcript"), + ): + out = runner.invoke(app, [str(audio), "--verbose"]) + + assert out.exit_code == 1 + assert "unexpected boom" in out.output + + +def test_cli_unexpected_error_no_verbose_hint(tmp_path): + """Unexpected error without --verbose → hint to use --verbose.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + with ( + patch("local_transcriber.cli.check_ffmpeg"), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch("local_transcriber.cli.ensure_model_available", return_value="/models/large-v3"), + patch("local_transcriber.cli.transcribe", side_effect=RuntimeError("unexpected boom")), + patch("local_transcriber.cli.write_transcript"), + ): + out = runner.invoke(app, [str(audio)]) + + assert out.exit_code == 1 + assert "Ошибка" in out.output + assert "--verbose" in out.output