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:
2026-03-17 23:38:32 +03:00
parent 0d1a734479
commit 3d14ed7b86
11 changed files with 804 additions and 12 deletions
+236
View File
@@ -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"
+22
View File
@@ -14,6 +14,7 @@ def test_format_timestamp_minutes():
assert format_timestamp(83.45) == "01:23.45"
assert format_timestamp(9.1) == "00:09.10"
assert format_timestamp(599.99) == "09:59.99"
assert format_timestamp(0.995) == "00:01.00" # carry-over: не даёт .100
def test_format_timestamp_hours():
@@ -49,10 +50,31 @@ def test_format_transcript_basic():
assert "**Длительность**: 02:00" in content
assert "**Устройство**: CUDA (NVIDIA GeForce RTX 3060)" in content
assert "---" in content
# Проверяем пробел между ] и текстом независимо от ведущих пробелов в seg.text
assert "[00:00.00 - 00:04.82] Добрый день, коллеги." in content
assert "[00:04.82 - 00:09.15] Первый вопрос." in content
def test_format_transcript_segment_no_leading_space():
"""Сегменты без ведущего пробела должны форматироваться корректно."""
result = TranscribeResult(
segments=[Segment(start=0.0, end=2.0, text="Hello")],
language="en",
language_probability=0.99,
duration=5.0,
device_used="cpu",
)
content = format_transcript(
result,
source_filename="f.mp3",
model_name="tiny",
device_info="CPU",
language_mode="detected",
transcription_date=datetime(2026, 1, 1, 0, 0, 0),
)
assert "[00:00.00 - 00:02.00] Hello" in content
def test_format_transcript_empty():
result = TranscribeResult(
segments=[],
+155 -1
View File
@@ -3,8 +3,9 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from huggingface_hub.errors import LocalEntryNotFoundError
from local_transcriber.transcriber import Segment, TranscribeResult, transcribe
from local_transcriber.transcriber import Segment, TranscribeResult, ensure_model_available, transcribe
def _make_raw_segments(count: int) -> list:
@@ -27,6 +28,16 @@ def _make_info(language: str = "ru", probability: float = 0.95, duration: float
return info
def _create_model_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
(path / "config.json").write_text("{}")
(path / "preprocessor_config.json").write_text("{}")
(path / "tokenizer.json").write_text("{}")
(path / "vocabulary.json").write_text("{}")
(path / "model.bin").write_bytes(b"ok")
return path
@patch("local_transcriber.transcriber.WhisperModel")
def test_transcribe_collects_segments(mock_model_cls):
raw_segments = _make_raw_segments(3)
@@ -201,3 +212,146 @@ def test_transcribe_midstream_fallback_no_duplicate_callbacks(mock_model_cls):
# This is acceptable — on_segment is a live progress callback.
# The important thing is that result.segments contains only CPU segments.
assert all(s.text.startswith(" Segment") for s in result.segments)
@patch("local_transcriber.transcriber.WhisperModel")
def test_transcribe_reports_missing_socksio_for_proxy(mock_model_cls):
mock_model_cls.side_effect = ImportError(
"Using SOCKS proxy, but the 'socksio' package is not installed."
)
with pytest.raises(RuntimeError, match="socksio"):
transcribe(
file_path=Path("test.mp3"),
model_name="tiny",
device="cpu",
)
@patch("local_transcriber.transcriber.WhisperModel")
def test_transcribe_reports_status_transitions(mock_model_cls):
raw_segments = _make_raw_segments(1)
info = _make_info()
instance = MagicMock()
instance.transcribe.return_value = (iter(raw_segments), info)
mock_model_cls.return_value = instance
statuses: list[str] = []
transcribe(
file_path=Path("test.mp3"),
model_name="tiny",
device="cpu",
on_status=statuses.append,
)
assert statuses == [
"Загружаю модель на cpu...",
"Транскрибирую...",
]
@patch("local_transcriber.transcriber.snapshot_download")
def test_ensure_model_available_uses_cache_first(mock_snapshot_download, tmp_path):
model_dir = _create_model_dir(tmp_path / "cache-model")
mock_snapshot_download.return_value = str(model_dir)
result = ensure_model_available("large-v3")
assert result == str(model_dir)
mock_snapshot_download.assert_called_once_with(
"Systran/faster-whisper-large-v3",
local_files_only=True,
allow_patterns=[
"config.json",
"preprocessor_config.json",
"model.bin",
"tokenizer.json",
"vocabulary.*",
],
)
@patch("local_transcriber.transcriber._validate_model_dir")
@patch("local_transcriber.transcriber.snapshot_download")
def test_ensure_model_available_downloads_on_cache_miss(mock_snapshot_download, mock_validate_model_dir):
mock_snapshot_download.side_effect = [
LocalEntryNotFoundError("not cached"),
"/downloaded/model",
]
statuses: list[str] = []
result = ensure_model_available("large-v3", on_status=statuses.append)
assert result == "/downloaded/model"
assert mock_snapshot_download.call_args_list[0].kwargs["local_files_only"] is True
assert mock_snapshot_download.call_args_list[1].kwargs["local_files_only"] is False
assert statuses == [
"Проверяю кэш модели large-v3...",
"Скачиваю модель large-v3 из Hugging Face...",
]
def test_ensure_model_available_accepts_local_directory(tmp_path):
model_dir = _create_model_dir(tmp_path / "model")
result = ensure_model_available(str(model_dir))
assert result == str(model_dir)
def test_ensure_model_available_accepts_repo_id(tmp_path):
model_dir = _create_model_dir(tmp_path / "repo-model")
with patch("local_transcriber.transcriber.snapshot_download", return_value=str(model_dir)) as mock_snapshot_download:
result = ensure_model_available("org/model")
assert result == str(model_dir)
assert mock_snapshot_download.call_args.kwargs["local_files_only"] is True
def test_ensure_model_available_rejects_unsupported_alias():
with pytest.raises(ValueError, match="Неподдерживаемая модель"):
ensure_model_available("distil-large-v3")
@patch("local_transcriber.transcriber.snapshot_download")
def test_ensure_model_available_redownloads_incomplete_cache(mock_snapshot_download, tmp_path):
incomplete = tmp_path / "incomplete"
incomplete.mkdir()
(incomplete / "config.json").write_text("{}")
(incomplete / "preprocessor_config.json").write_text("{}")
(incomplete / "tokenizer.json").write_text("{}")
(incomplete / "vocabulary.json").write_text("{}")
complete = tmp_path / "complete"
complete.mkdir()
(complete / "config.json").write_text("{}")
(complete / "preprocessor_config.json").write_text("{}")
(complete / "tokenizer.json").write_text("{}")
(complete / "vocabulary.json").write_text("{}")
(complete / "model.bin").write_bytes(b"ok")
mock_snapshot_download.side_effect = [
str(incomplete),
str(complete),
]
statuses: list[str] = []
result = ensure_model_available("large-v3", on_status=statuses.append)
assert result == str(complete)
assert statuses == [
"Проверяю кэш модели large-v3...",
"Кэш модели large-v3 неполный, докачиваю...",
"Скачиваю модель large-v3 из Hugging Face...",
]
def test_ensure_model_available_rejects_incomplete_local_directory(tmp_path):
model_dir = tmp_path / "model"
model_dir.mkdir()
(model_dir / "config.json").write_text("{}")
with pytest.raises(ValueError, match="Неполная локальная модель"):
ensure_model_available(str(model_dir))