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:
+155
-1
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user