"""포터블 빌드(= torch 없는 환경) 검증. 포터블 exe 는 torch 를 넣지 않는다. torch+CUDA 만 2.5GB 라 onefile 로 묶으면 실행할 때마다 임시폴더에 푸느라 못 쓸 만큼 느려지기 때문이다. 그 대신 **1~3티어(CTranslate2)는 torch 없이 전부 돌아야 한다.** 이 파일은 그 전제가 깨지지 않았는지 지킨다. 특히 `CTranslate2Translator.load()` 가 torch 없이 통과하는지 — 한 번 이걸 놓쳐서 포터블이 실행 즉시 죽을 뻔했다. """ from __future__ import annotations import ast import importlib import importlib.util from pathlib import Path import pytest from livesub.models.manager import first_available_tier, has_torch, tier_availability from livesub.models.tiers import TIERS, MTBackend, ordered_tiers SPEC = Path(__file__).resolve().parents[1] / "packaging" / "livesub.spec" PORTABLE_REQS = Path(__file__).resolve().parents[1] / "packaging" / "requirements-portable.txt" #: CTranslate2 경로가 실제로 import 하는 것들. 포터블에 반드시 들어가야 한다. CT2_RUNTIME_IMPORTS = ["ctranslate2", "transformers", "huggingface_hub", "faster_whisper"] def _spec_list(name: str) -> list[str]: """spec 파일에서 excludes / hiddenimports 리스트를 정적으로 읽는다.""" tree = ast.parse(SPEC.read_text(encoding="utf-8")) for node in ast.walk(tree): if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == name: return [ e.value for e in node.value.elts if isinstance(e, ast.Constant) ] raise AssertionError(f"spec 에 {name} 가 없습니다") def test_spec_excludes_torch(): """torch 가 들어가면 exe 가 3GB 로 불어나 포터블의 의미가 사라진다.""" assert "torch" in _spec_list("excludes") def test_spec_does_not_exclude_transformers(): """NLLB 토크나이저가 transformers 를 쓰므로 제외하면 안 된다. 한 번 제외했다가 포터블에서 번역 모델 로드가 통째로 실패할 뻔했다. """ assert "transformers" not in _spec_list("excludes") @pytest.mark.parametrize("module", CT2_RUNTIME_IMPORTS) def test_ct2_runtime_deps_are_in_portable_requirements(module): text = PORTABLE_REQS.read_text(encoding="utf-8") name = module.replace("_", "-") assert name in text or module in text, f"{module} 가 포터블 의존성에 없습니다" def test_portable_requirements_have_no_torch(): text = PORTABLE_REQS.read_text(encoding="utf-8").lower() for line in text.splitlines(): line = line.split("#")[0].strip() assert not line.startswith("torch"), f"포터블에 torch 가 있습니다: {line}" # --- torch 없는 환경에서의 실제 동작 ----------------------------------------- @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_ct2_translator_loads_without_torch(): """리뷰에서 지적된 지점 — 포터블에서 load() 가 죽으면 앱을 못 쓴다. 모델 가중치는 받지 않고, load() 가 실제로 부르는 import 들이 torch 없이 통과하는지만 본다. """ for module in CT2_RUNTIME_IMPORTS: assert importlib.util.find_spec(module) is not None, f"{module} 없음" from transformers import AutoTokenizer # noqa: F401 - import 자체가 검증 importlib.import_module("ctranslate2") @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_ct2_tiers_are_usable_without_torch(): for tier in ordered_tiers(): if tier.mt.backend is MTBackend.CTRANSLATE2: assert tier_availability(tier)[0], f"{tier.key} 가 포터블에서 막혔습니다" @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_llm_tiers_are_blocked_without_torch(): """쓸 수 없는 티어는 '사용 불가'로 막고 이유를 알려줘야 한다.""" for tier in TIERS.values(): if tier.mt.backend is MTBackend.TRANSFORMERS: ok, reason = tier_availability(tier) assert not ok assert "PyTorch" in reason @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_default_tier_is_usable_in_portable(): """기본 티어가 포터블에서 못 쓰는 것이면 첫 실행부터 막힌다.""" from livesub.config import AppConfig assert tier_availability(TIERS[AppConfig().models.tier])[0] @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_unavailable_tier_falls_back_to_best_usable(): assert first_available_tier("precision") == "balance" assert first_available_tier("ultimate") == "balance" @pytest.mark.skipif(has_torch(), reason="torch 가 설치된 환경 — 포터블 조건이 아님") def test_app_window_builds_without_torch(tmp_path, monkeypatch): """포터블에서 앱 전체가 뜨는지 (오디오 장치도 GPU도 없이).""" pytest.importorskip("PySide6") import os os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PySide6.QtWidgets import QApplication from livesub.config import AppConfig from livesub.ui.main_window import MainWindow app = QApplication.instance() or QApplication([]) cfg = AppConfig() cfg.models.preload_on_start = False cfg.glossary.path = str(tmp_path / "g.json") win = MainWindow(cfg) try: assert win.stack.count() == 5 finally: win.engine.shutdown() win.overlay.close() win.close() _ = app