feat(cli): реализован шаг 5 — CLI-связка всех модулей с исправлениями из ревью
- Зачем:
- шаг 5 плана: нужен рабочий CLI-happy path, связывающий utils / transcriber / formatter.
- ревью этапов 4–5 выявило два medium-бага в formatter и отсутствие тестов для CLI.
- Что:
- cli.py: все опции по PRD 3.2 (--model, --language, --output, --device, --compute-type, --verbose),
rich Status + stderr-консоль, предупреждение на пустую речь, статистика времени.
- transcriber.py: добавлена ensure_model_available() с проверкой кэша HF и валидацией
локальной директории; on_status callback для передачи прогресса в CLI; обработка
ImportError при отсутствии socksio через SOCKS proxy.
- formatter.py: исправлен overflow в format_timestamp (0.995 → 00:01.00 вместо 00:00.100);
сегменты теперь пишутся с явным пробелом и strip() независимо от whisper-формата текста.
- deps: добавлен socksio>=1.0.0 для поддержки SOCKS proxy при загрузке модели.
- tests: test_cli.py (8 тестов на CLI-контракт), расширены test_formatter.py и test_transcriber.py.
- Проверка:
- uv run pytest — 42 passed.
- uv run transcribe --help показывает все опции.
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from local_transcriber.cli import app
|
||||
from local_transcriber.transcriber import Segment, TranscribeResult
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _make_result(segments=None, language="ru", device_used="cpu", duration=60.0):
|
||||
return TranscribeResult(
|
||||
segments=[Segment(start=0.0, end=2.0, text="Hello")] if segments is None else segments,
|
||||
language=language,
|
||||
language_probability=0.95,
|
||||
duration=duration,
|
||||
device_used=device_used,
|
||||
)
|
||||
|
||||
|
||||
def _patches(result=None, tmp_file=None):
|
||||
"""Context managers for a standard CLI happy path."""
|
||||
if result is None:
|
||||
result = _make_result()
|
||||
return [
|
||||
patch("local_transcriber.cli.check_ffmpeg"),
|
||||
patch("local_transcriber.cli.validate_input_file", return_value=tmp_file),
|
||||
patch("local_transcriber.cli.detect_device", return_value="cpu"),
|
||||
patch("local_transcriber.cli.transcribe", return_value=result),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
]
|
||||
|
||||
|
||||
def test_cli_happy_path_exit_code_zero(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
|
||||
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", return_value=result),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
|
||||
|
||||
def test_cli_default_options_passed_to_transcribe(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
|
||||
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", mock_transcribe),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio)])
|
||||
|
||||
call_kwargs = mock_transcribe.call_args[1]
|
||||
assert call_kwargs["model_name"] == "/models/large-v3"
|
||||
assert call_kwargs["device"] == "cpu"
|
||||
assert call_kwargs["compute_type"] == "int8"
|
||||
assert call_kwargs["language"] is None # "auto" → None passed to transcribe
|
||||
assert call_kwargs["on_segment"] is None # verbose=False
|
||||
|
||||
|
||||
def test_cli_custom_options(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
|
||||
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="cuda"),
|
||||
patch("local_transcriber.cli.ensure_model_available", return_value="/models/small"),
|
||||
patch("local_transcriber.cli.transcribe", mock_transcribe),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"),
|
||||
):
|
||||
runner.invoke(app, [
|
||||
str(audio),
|
||||
"--model", "small",
|
||||
"--language", "ru",
|
||||
"--device", "cuda",
|
||||
"--compute-type", "float16",
|
||||
])
|
||||
|
||||
call_kwargs = mock_transcribe.call_args[1]
|
||||
assert call_kwargs["model_name"] == "/models/small"
|
||||
assert call_kwargs["language"] == "ru" # explicit language passed through
|
||||
assert call_kwargs["compute_type"] == "float16"
|
||||
|
||||
|
||||
def test_cli_verbose_passes_on_segment_callback(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
|
||||
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", mock_transcribe),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio), "--verbose"])
|
||||
|
||||
call_kwargs = mock_transcribe.call_args[1]
|
||||
assert call_kwargs["on_segment"] is not None
|
||||
assert callable(call_kwargs["on_segment"])
|
||||
|
||||
|
||||
def test_cli_empty_speech_warning(tmp_path):
|
||||
audio = tmp_path / "silence.wav"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result(segments=[])
|
||||
|
||||
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", return_value=result),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 0
|
||||
assert "Речь не обнаружена" in out.output
|
||||
|
||||
|
||||
def test_cli_default_output_path(tmp_path):
|
||||
audio = tmp_path / "meeting.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_write = MagicMock()
|
||||
|
||||
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", return_value=result),
|
||||
patch("local_transcriber.cli.write_transcript", mock_write),
|
||||
):
|
||||
runner.invoke(app, [str(audio)])
|
||||
|
||||
written_path: Path = mock_write.call_args[0][1]
|
||||
assert written_path.name == "meeting-transcript.md"
|
||||
|
||||
|
||||
def test_cli_custom_output_path(tmp_path):
|
||||
audio = tmp_path / "meeting.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
out_file = tmp_path / "custom.md"
|
||||
result = _make_result()
|
||||
mock_write = MagicMock()
|
||||
|
||||
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", return_value=result),
|
||||
patch("local_transcriber.cli.write_transcript", mock_write),
|
||||
):
|
||||
runner.invoke(app, [str(audio), "--output", str(out_file)])
|
||||
|
||||
written_path: Path = mock_write.call_args[0][1]
|
||||
assert written_path == out_file
|
||||
|
||||
|
||||
def test_cli_error_exit_code_one(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
|
||||
with patch("local_transcriber.cli.check_ffmpeg", side_effect=SystemExit(1)):
|
||||
out = runner.invoke(app, [str(audio)])
|
||||
|
||||
assert out.exit_code == 1
|
||||
|
||||
|
||||
def test_cli_passes_status_callback_to_transcribe(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
|
||||
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", mock_transcribe),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio)])
|
||||
|
||||
call_kwargs = mock_transcribe.call_args[1]
|
||||
assert call_kwargs["on_status"] is not None
|
||||
assert callable(call_kwargs["on_status"])
|
||||
|
||||
|
||||
def test_cli_resolves_model_before_transcribe(tmp_path):
|
||||
audio = tmp_path / "test.mp3"
|
||||
audio.write_bytes(b"fake")
|
||||
result = _make_result()
|
||||
mock_transcribe = MagicMock(return_value=result)
|
||||
|
||||
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") as mock_ensure_model,
|
||||
patch("local_transcriber.cli.transcribe", mock_transcribe),
|
||||
patch("local_transcriber.cli.write_transcript"),
|
||||
):
|
||||
runner.invoke(app, [str(audio), "--model", "large-v3"])
|
||||
|
||||
mock_ensure_model.assert_called_once()
|
||||
call_kwargs = mock_transcribe.call_args[1]
|
||||
assert call_kwargs["model_name"] == "/models/large-v3"
|
||||
Reference in New Issue
Block a user