From 510d6ccfc9b9c3801396b850a46ae33cc3d10e87 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 17 Mar 2026 21:51:22 +0300 Subject: [PATCH] =?UTF-8?q?feat(utils):=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B8=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20(=D1=88=D0=B0=D0=B3=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - необходимы утилиты для проверки ffmpeg, определения устройства и валидации входного файла перед запуском транскрипции. - Что: - check_ffmpeg() завершает процесс с понятным сообщением, если ffmpeg не в PATH. - detect_device() возвращает "cuda" при наличии nvidia-smi, иначе "cpu". - get_gpu_name() получает имя GPU через nvidia-smi или возвращает None. - validate_input_file() проверяет существование, тип и размер файла; неизвестное расширение — warning, не ошибка. - build_output_path() формирует путь -transcript.md рядом с исходником. - Проверка: - uv run pytest tests/test_utils.py -v — 9 passed. - uv run python -c "from local_transcriber.utils import check_ffmpeg, detect_device; check_ffmpeg(); print(detect_device())" --- docs/plan.md | 12 +++--- src/local_transcriber/utils.py | 57 +++++++++++++++++++++++--- tests/test_utils.py | 74 ++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 11 deletions(-) diff --git a/docs/plan.md b/docs/plan.md index 07b36f1..8a211c2 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -119,10 +119,10 @@ > PRD-ссылки: 3.1 (flow), 3.2 (опции --device), 3.4 (форматы), 4.2 (ffmpeg) -- [ ] `check_ffmpeg()`: +- [x] `check_ffmpeg()`: - `subprocess.run(["ffmpeg", "-version"], capture_output=True)` - При `FileNotFoundError` → `SystemExit` с сообщением и инструкцией: `apt install ffmpeg` / `winget install ffmpeg` / `brew install ffmpeg` -- [ ] `detect_device(requested: str = "auto") -> str`: +- [x] `detect_device(requested: str = "auto") -> str`: - Если `requested != "auto"` → вернуть `requested` - Иначе: проверить CUDA через `shutil.which("nvidia-smi")` как быстрый хинт - Если nvidia-smi найден → `"cuda"` @@ -130,19 +130,19 @@ - **Не импортировать** ctranslate2 или torch здесь — faster-whisper ещё не в зависимостях - Точная проверка CUDA будет при загрузке модели (шаг 3), здесь — best effort - `--compute-type` остаётся независимым параметром CLI, не связан с detect_device -- [ ] `get_gpu_name() -> str | None`: +- [x] `get_gpu_name() -> str | None`: - `subprocess.run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], capture_output=True)` - Вернуть первую строку stdout (strip) или `None` если nvidia-smi недоступен / ошибка - Используется для формирования `device_info` в шапке markdown: `"CUDA (NVIDIA GeForce RTX 3060)"` или `"CPU"` -- [ ] `validate_input_file(path: Path) -> Path`: +- [x] `validate_input_file(path: Path) -> Path`: - Проверить: существует, является файлом (не директорией), размер > 0 - Расширение из допустимых (PRD 3.4) → если нет, **warning** (не ошибка), продолжить - Вернуть `path.resolve()` -- [ ] `build_output_path(input_path: Path, output: Path | None = None) -> Path`: +- [x] `build_output_path(input_path: Path, output: Path | None = None) -> Path`: - Если `output` задан → вернуть его - Иначе → `input_path.with_stem(input_path.stem + "-transcript").with_suffix(".md")` -- [ ] Тесты в `tests/test_utils.py`: +- [x] Тесты в `tests/test_utils.py`: - `test_validate_input_file_not_found` — несуществующий файл → ошибка - `test_validate_input_file_empty` — пустой файл → ошибка - `test_validate_input_file_unknown_ext` — `.txt` → warning, но не ошибка diff --git a/src/local_transcriber/utils.py b/src/local_transcriber/utils.py index 0414c3e..d0b44e8 100644 --- a/src/local_transcriber/utils.py +++ b/src/local_transcriber/utils.py @@ -1,21 +1,68 @@ +import shutil +import subprocess +import sys +import warnings from pathlib import Path +SUPPORTED_EXTENSIONS = { + ".mp3", ".wav", ".flac", ".ogg", ".m4a", ".wma", ".aac", + ".mp4", ".mkv", ".avi", ".mov", ".webm", ".ts", +} + def check_ffmpeg() -> None: - raise NotImplementedError + try: + subprocess.run(["ffmpeg", "-version"], capture_output=True, check=False) + except FileNotFoundError: + sys.exit( + "ffmpeg не найден в PATH. Установите ffmpeg:\n" + " Linux: apt install ffmpeg\n" + " Windows: winget install ffmpeg\n" + " macOS: brew install ffmpeg" + ) def detect_device(requested: str = "auto") -> str: - raise NotImplementedError + if requested != "auto": + return requested + if shutil.which("nvidia-smi") is not None: + return "cuda" + return "cpu" def get_gpu_name() -> str | None: - raise NotImplementedError + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + name = result.stdout.strip().splitlines()[0].strip() + return name if name else None + except FileNotFoundError: + pass + return None def validate_input_file(path: Path) -> Path: - raise NotImplementedError + if not path.exists(): + raise FileNotFoundError(f"Файл не найден: {path}") + if not path.is_file(): + raise ValueError(f"Путь не является файлом: {path}") + if path.stat().st_size == 0: + raise ValueError(f"Файл пустой: {path}") + if path.suffix.lower() not in SUPPORTED_EXTENSIONS: + warnings.warn( + f"Расширение '{path.suffix}' не входит в список поддерживаемых. " + "Попытка продолжить.", + stacklevel=2, + ) + return path.resolve() def build_output_path(input_path: Path, output: Path | None = None) -> Path: - raise NotImplementedError + if output is not None: + return output + return input_path.with_stem(input_path.stem + "-transcript").with_suffix(".md") diff --git a/tests/test_utils.py b/tests/test_utils.py index e69de29..649cbaf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -0,0 +1,74 @@ +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest + +from local_transcriber.utils import ( + build_output_path, + detect_device, + get_gpu_name, + validate_input_file, +) + + +def test_validate_input_file_not_found(tmp_path): + missing = tmp_path / "no_such_file.mp3" + with pytest.raises(FileNotFoundError): + validate_input_file(missing) + + +def test_validate_input_file_empty(tmp_path): + empty = tmp_path / "empty.mp3" + empty.touch() + with pytest.raises(ValueError, match="пустой"): + validate_input_file(empty) + + +def test_validate_input_file_unknown_ext(tmp_path): + f = tmp_path / "notes.txt" + f.write_text("hello") + with pytest.warns(UserWarning, match="не входит в список"): + result = validate_input_file(f) + assert result == f.resolve() + + +def test_validate_input_file_ok(tmp_path): + f = tmp_path / "audio.mp3" + f.write_bytes(b"\x00" * 16) + result = validate_input_file(f) + assert result == f.resolve() + + +def test_build_output_path_default(): + inp = Path("/some/dir/meeting-2026-03-17.mp4") + result = build_output_path(inp) + assert result == Path("/some/dir/meeting-2026-03-17-transcript.md") + + +def test_build_output_path_custom(): + inp = Path("/some/dir/audio.mp3") + custom = Path("/out/result.md") + result = build_output_path(inp, output=custom) + assert result == custom + + +def test_detect_device_explicit(): + assert detect_device("cpu") == "cpu" + assert detect_device("cuda") == "cuda" + + +def test_get_gpu_name_no_nvidia_smi(): + with patch("shutil.which", return_value=None): + with patch("subprocess.run", side_effect=FileNotFoundError): + result = get_gpu_name() + assert result is None + + +def test_get_gpu_name_success(): + mock_result = subprocess.CompletedProcess( + args=[], returncode=0, stdout="NVIDIA GeForce RTX 3060\n", stderr="" + ) + with patch("subprocess.run", return_value=mock_result): + result = get_gpu_name() + assert result == "NVIDIA GeForce RTX 3060"