feat(cli): реализована обработка ошибок и graceful Ctrl+C (шаг 6)
- Зачем: - пользователь видел 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 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -261,10 +261,10 @@
|
|||||||
|
|
||||||
> PRD-ссылки: 6 (риски)
|
> PRD-ссылки: 6 (риски)
|
||||||
|
|
||||||
- [ ] Graceful Ctrl+C: перехват `KeyboardInterrupt` в cli.py → `console.print("Прервано пользователем", style="yellow")` + `raise SystemExit(130)`
|
- [x] Graceful Ctrl+C: перехват `KeyboardInterrupt` в cli.py → `console.print("Прервано пользователем", style="yellow")` + `raise SystemExit(130)`
|
||||||
- [ ] Красивые ошибки: обернуть main в try/except, для пользовательских ошибок (файл не найден, ffmpeg, OOM) — вывод через rich без traceback; для неожиданных — traceback только с `--verbose`
|
- [x] Красивые ошибки: обернуть main в try/except, для пользовательских ошибок (файл не найден, ffmpeg, OOM) — вывод через rich без traceback; для неожиданных — traceback только с `--verbose`
|
||||||
- [ ] `--verbose` режим: реализуется через `on_segment` callback в `transcribe()` (уже заложен в шаге 3) — печатать каждый сегмент в stderr по мере поступления
|
- [x] `--verbose` режим: реализуется через `on_segment` callback в `transcribe()` (уже заложен в шаге 3) — печатать каждый сегмент в stderr по мере поступления
|
||||||
- [ ] Проверка: неподдерживаемое расширение → warning, но попытка продолжить
|
- [x] Проверка: неподдерживаемое расширение → warning, но попытка продолжить
|
||||||
|
|
||||||
**Критерий готовности**: ручной прогон edge cases — несуществующий файл, .txt файл, Ctrl+C во время работы.
|
**Критерий готовности**: ручной прогон edge cases — несуществующий файл, .txt файл, Ctrl+C во время работы.
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,45 @@ def main(
|
|||||||
device: str = typer.Option("auto", "--device", "-d", help="Устройство (auto|cpu|cuda)"),
|
device: str = typer.Option("auto", "--device", "-d", help="Устройство (auto|cpu|cuda)"),
|
||||||
compute_type: str = typer.Option("int8", "--compute-type", help="Тип вычислений"),
|
compute_type: str = typer.Option("int8", "--compute-type", help="Тип вычислений"),
|
||||||
verbose: bool = typer.Option(False, "--verbose", "-v", 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:
|
) -> None:
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
|
|
||||||
@@ -41,7 +80,6 @@ def main(
|
|||||||
def on_segment(seg: Segment) -> None:
|
def on_segment(seg: Segment) -> None:
|
||||||
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
||||||
|
|
||||||
try:
|
|
||||||
with Status("Подготавливаю запуск...", console=console) as status:
|
with Status("Подготавливаю запуск...", console=console) as status:
|
||||||
result = transcribe(
|
result = transcribe(
|
||||||
file_path=validated_file,
|
file_path=validated_file,
|
||||||
@@ -53,17 +91,6 @@ def main(
|
|||||||
on_status=status.update,
|
on_status=status.update,
|
||||||
strict_device=strict,
|
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
|
|
||||||
|
|
||||||
if result.device_used != resolved_device:
|
if result.device_used != resolved_device:
|
||||||
if requested_device == "auto":
|
if requested_device == "auto":
|
||||||
|
|||||||
@@ -333,3 +333,73 @@ def test_cli_strict_device_passed_to_transcribe(tmp_path):
|
|||||||
runner.invoke(app, [str(audio)])
|
runner.invoke(app, [str(audio)])
|
||||||
|
|
||||||
assert mock_transcribe.call_args[1]["strict_device"] is False
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user