From ac8ea508ceea2ef145ffa074be5a3b8c3fa850a1 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Wed, 18 Mar 2026 23:25:17 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20docstrings=20=D0=B8=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D1=88=D0=B8=D1=80=D0=B5=D0=BD=20CONTRIBUTING.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - покрытие документацией было неравномерным, отсутствовало архитектурное описание - Что: - добавлены модульные docstrings во все 5 модулей (utils, config, formatter, transcriber, cli) - добавлены docstrings для всех публичных функций без документации - добавлены inline-комментарии для неочевидной логики (CUDA fallback, strict device, glob, SOCKS proxy) - CONTRIBUTING.md: секции «Архитектура», «Ключевые решения», «Тестирование», «Частые задачи» - обновлено правило языка комментариев (русский вместо английского) - Проверка: - uv run pytest (97 passed, 1 skipped) Co-Authored-By: Claude Opus 4.6 --- CONTRIBUTING.md | 34 +++++++++++++++++++++++++++- src/local_transcriber/cli.py | 12 +++++++++- src/local_transcriber/config.py | 10 ++++++++ src/local_transcriber/formatter.py | 9 ++++++++ src/local_transcriber/transcriber.py | 16 +++++++++++++ src/local_transcriber/utils.py | 21 +++++++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 509ffa9..05fce13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,39 @@ tests/ - Тесты: `uv run pytest` должен проходить перед PR - Стиль: стандартный Python (ruff-совместимый) - Коммиты: [Conventional Commits](https://www.conventionalcommits.org/) -- Язык кода: английский (имена, комментарии); UI-строки для пользователя — русский +- Язык кода: английский (имена переменных/функций); docstrings, комментарии и UI-строки — русский + +## Архитектура + +``` +CLI (cli.py) + → config.py: загрузка .transcriber.toml, каскад дефолтов + → utils.py: валидация файлов, определение устройства + → transcriber.py: загрузка модели (с CUDA fallback), транскрипция + → formatter.py: сегменты → markdown с таймкодами + → запись результата +``` + +## Ключевые архитектурные решения + +- **CUDA fallback** — двухуровневый: при загрузке модели и при транскрипции (mid-stream). GPU может быть видна через nvidia-smi, но не иметь достаточно VRAM. +- **Device-aware дефолты** — `compute_type` зависит от устройства (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU. +- **cuBLAS bootstrap** (`_cuda_bootstrap.py`) — preload через ctypes до импорта ctranslate2. pip-пакет `nvidia-cublas-cu12` ставит `.so` в нестандартное место, а `LD_LIBRARY_PATH` нельзя изменить в рантайме. +- **Батч-режим** — 3 фазы (prescan → load model → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки. +- **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно. + +## Тестирование + +- Все CLI-тесты через `typer.testing.CliRunner` + моки (faster-whisper не вызывается) +- Моки: `load_config`, `validate_input_file`, `detect_device`, `ensure_model_available`, `load_model`, `_transcribe_file`, `write_transcript` +- Паттерн: `_single_patches()` — хелпер для стандартного happy-path набора моков +- `_make_result()` / `_make_tfr()` — фабрики тестовых данных + +## Частые задачи + +- **Новая CLI-опция**: добавить `typer.Option` в `main()` → добавить ключ в `HARDCODED_DEFAULTS` в `config.py` → написать тест +- **Поддержка нового формата**: добавить расширение в `SUPPORTED_EXTENSIONS` в `utils.py` +- **Изменение формата вывода**: редактировать `format_transcript()` в `formatter.py` ## Как сделать PR diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index 010c3e9..dd45a2c 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -1,3 +1,5 @@ +"""CLI-точка входа (typer). Single и batch режимы транскрипции.""" + import sys import time from pathlib import Path @@ -49,6 +51,10 @@ def main( verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), force: bool = typer.Option(False, "--force", "-f", help="Перезаписать существующие транскрипты"), ) -> None: + """Транскрибирует аудио/видеофайлы в markdown с таймкодами. + + Каскад приоритетов параметров: CLI-флаги > .transcriber.toml > device-aware дефолты. + """ try: config = load_config() cli_values = {"model": model, "language": language, "device": device, "compute_type": compute_type} @@ -108,11 +114,13 @@ def _run_single( output: Path | None, verbose: bool, ) -> None: + """Пайплайн одного файла: валидация → модель → транскрипция → запись.""" start = time.monotonic() validated_file = validate_input_file(file) requested_device = defaults["device"] resolved_device = detect_device(requested_device) + # Если пользователь явно указал устройство — запрещаем fallback на CPU strict = requested_device != "auto" output_path = build_output_path(validated_file, output) @@ -196,7 +204,8 @@ def _run_batch( verbose: bool, force: bool, ) -> None: - # Phase 1: Prescan + """Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция.""" + # Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек) to_process: list[Path] = [] skipped = 0 invalid = 0 @@ -282,6 +291,7 @@ def _run_batch( f" {file.name}: fallback на {tfr.actual_device} при транскрипции", style="yellow", ) + # Обновляем после возможного mid-stream fallback на CPU model_obj, actual_device = tfr.model, tfr.actual_device result = tfr.result diff --git a/src/local_transcriber/config.py b/src/local_transcriber/config.py index c4270ef..9ba8cdf 100644 --- a/src/local_transcriber/config.py +++ b/src/local_transcriber/config.py @@ -1,3 +1,5 @@ +"""Загрузка конфигурации из ``.transcriber.toml`` и каскад приоритетов.""" + import sys import warnings from pathlib import Path @@ -19,11 +21,13 @@ DEVICE_DEFAULTS: dict[str, dict[str, str]] = { "cpu": {"model": "medium", "compute_type": "float32"}, } +# Одно место правды для допустимых ключей конфига _VALID_KEYS = set(HARDCODED_DEFAULTS) _VALID_DEVICES = {"auto", "cpu", "cuda"} def find_config_file() -> Path | None: + """Ищет конфиг: сначала ``.transcriber.toml`` в cwd, затем ``~/.config/transcriber/config.toml``.""" cwd_config = Path.cwd() / ".transcriber.toml" if cwd_config.is_file(): return cwd_config @@ -36,6 +40,11 @@ def find_config_file() -> Path | None: def load_config(path: Path | None = None) -> dict[str, str]: + """Загружает и валидирует TOML-конфиг. + + Неизвестные ключи вызывают предупреждение (а не ошибку) для forward + compatibility: новые версии могут добавить ключи, которых ещё нет в текущей. + """ if path is None: path = find_config_file() if path is None: @@ -78,6 +87,7 @@ def load_config(path: Path | None = None) -> dict[str, str]: def resolve_defaults( cli_values: dict[str, str | None], config: dict[str, str] ) -> dict[str, str]: + """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.""" result: dict[str, str] = {} for key in HARDCODED_DEFAULTS: cli_val = cli_values.get(key) diff --git a/src/local_transcriber/formatter.py b/src/local_transcriber/formatter.py index b0d8b22..609ac08 100644 --- a/src/local_transcriber/formatter.py +++ b/src/local_transcriber/formatter.py @@ -1,3 +1,5 @@ +"""Формирование markdown-транскрипта из результатов распознавания.""" + from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -42,6 +44,10 @@ def _group_segments(segments: list[Segment]) -> list[_Paragraph]: def format_timestamp(seconds: float, use_hours: bool = False) -> str: + """Форматирует время в ``MM:SS.cc`` или ``HH:MM:SS.cc``. + + Сотые доли (centiseconds) — максимальная точность, которую даёт Whisper. + """ total_cs = round(seconds * 100) centiseconds = total_cs % 100 total_seconds = total_cs // 100 @@ -58,6 +64,7 @@ def format_timestamp(seconds: float, use_hours: bool = False) -> str: def _format_duration(seconds: float) -> str: + """Человекочитаемая длительность для метаданных в шапке транскрипта.""" total = int(seconds) h = total // 3600 m = (total % 3600) // 60 @@ -75,6 +82,7 @@ def format_transcript( language_mode: str, # "detected" | "forced" transcription_date: datetime | None = None, # None -> datetime.now() ) -> str: + """Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами.""" date = transcription_date or datetime.now() use_hours = result.duration > 3600 @@ -104,5 +112,6 @@ def format_transcript( def write_transcript(content: str, output_path: Path) -> None: + """Записывает готовый транскрипт в файл.""" with open(output_path, "w", encoding="utf-8") as f: f.write(content) diff --git a/src/local_transcriber/transcriber.py b/src/local_transcriber/transcriber.py index 6050158..70b1636 100644 --- a/src/local_transcriber/transcriber.py +++ b/src/local_transcriber/transcriber.py @@ -1,3 +1,5 @@ +"""Обёртка над faster-whisper: загрузка моделей, транскрипция, CUDA fallback.""" + import warnings from collections.abc import Callable from dataclasses import dataclass @@ -20,6 +22,8 @@ MODEL_REPOS = { "large-v3": "Systran/faster-whisper-large-v3", } +# allow — фильтр для snapshot_download (какие файлы скачивать из репозитория); +# required — для валидации (что обязано быть после скачивания/в локальной модели) MODEL_ALLOW_PATTERNS = [ "config.json", "preprocessor_config.json", @@ -71,6 +75,7 @@ def load_model( _notify_status(on_status, f"Инициализирую модель на {device}...") model = _create_model(model_name, device, compute_type) except (RuntimeError, ValueError) as exc: + # strict — пользователь явно указал устройство, fallback запрещён if device != "cpu" and _is_cuda_error(exc): if strict_device: raise @@ -105,6 +110,8 @@ def _transcribe_file( _notify_status(on_status, "Транскрибирую...") segments, info = _run_transcription(model, file_path, lang_arg, on_segment, on_status) except (RuntimeError, ValueError) as exc: + # Mid-stream fallback: GPU может упасть с OOM уже во время транскрипции, + # поэтому перезагружаем модель на CPU и начинаем сначала if actual_device != "cpu" and _is_cuda_error(exc): if strict_device: raise @@ -141,6 +148,7 @@ def transcribe( on_status: Callable[[str], None] | None = None, strict_device: bool = False, ) -> TranscribeResult: + """High-level API: загрузка модели + транскрипция за один вызов.""" model, actual_device = load_model(model_name, device, compute_type, on_status, strict_device) tfr = _transcribe_file( model, actual_device, file_path, model_name, compute_type, @@ -153,6 +161,11 @@ def ensure_model_available( model_name: str, on_status: Callable[[str], None] | None = None, ) -> str: + """Резолвит alias модели в repo_id и гарантирует наличие файлов. + + Стратегия: cache-first (``local_files_only=True``), затем download. + Два вызова ``snapshot_download`` — чтобы не лезть в сеть, если модель уже в кэше. + """ local_path = Path(model_name).expanduser() if local_path.is_dir(): _validate_model_dir(local_path) @@ -198,6 +211,9 @@ def _create_model(model_name: str, device: str, compute_type: str): try: return WhisperModel(model_name, device=device, compute_type=compute_type) except ImportError as exc: + # WhisperModel при инициализации может загружать файлы через HF Hub; + # если в системе настроен SOCKS proxy, но socksio не установлен, + # HF Hub бросает ImportError — оборачиваем в понятное сообщение if _is_missing_socksio_error(exc): raise RuntimeError( "Обнаружен SOCKS proxy, но не установлена зависимость `socksio`, " diff --git a/src/local_transcriber/utils.py b/src/local_transcriber/utils.py index 07085fb..bb22fa4 100644 --- a/src/local_transcriber/utils.py +++ b/src/local_transcriber/utils.py @@ -1,3 +1,5 @@ +"""Утилиты для валидации входных файлов, определения устройства и работы с путями.""" + import glob import shutil import subprocess @@ -11,6 +13,11 @@ SUPPORTED_EXTENSIONS = { def detect_device(requested: str = "auto") -> str: + """Определяет устройство для вычислений. + + При ``requested="auto"`` проверяет наличие ``nvidia-smi`` в PATH + и возвращает ``"cuda"`` или ``"cpu"``. Явное значение возвращается как есть. + """ if requested != "auto": return requested if shutil.which("nvidia-smi") is not None: @@ -19,6 +26,7 @@ def detect_device(requested: str = "auto") -> str: def get_gpu_name() -> str | None: + """Возвращает название GPU через ``nvidia-smi`` (для метаданных транскрипта).""" try: result = subprocess.run( ["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], @@ -37,6 +45,12 @@ def get_gpu_name() -> str | None: def validate_input_file(path: Path) -> Path: + """Проверяет существование, непустоту и расширение файла. + + Неизвестное расширение — предупреждение, а не ошибка: файл может оказаться + корректным контейнером, просто с нестандартным суффиксом. + Возвращает resolved-путь. + """ if not path.exists(): raise FileNotFoundError(f"Файл не найден: {path}") if not path.is_file(): @@ -53,12 +67,18 @@ def validate_input_file(path: Path) -> Path: def build_output_path(input_path: Path, output: Path | None = None) -> Path: + """Строит путь выходного файла: ``<имя>-transcript.md`` рядом с исходным.""" if output is not None: return output return input_path.with_stem(input_path.stem + "-transcript").with_suffix(".md") def expand_globs(paths: list[Path]) -> list[Path]: + """Раскрывает glob-паттерны в списке путей с дедупликацией. + + Typer на Windows не раскрывает ``*.mp4`` самостоятельно, + поэтому глобы обрабатываются вручную. Дедупликация — по resolved-пути. + """ seen: set[Path] = set() result: list[Path] = [] for p in paths: @@ -76,4 +96,5 @@ def expand_globs(paths: list[Path]) -> list[Path]: def has_existing_transcript(input_path: Path) -> bool: + """Проверяет наличие транскрипта для skip-логики батч-режима.""" return build_output_path(input_path).exists()