Files
live-app-translator/tests/test_portable.py
EJClaw 628280b227
Some checks failed
Windows / verify (push) Has been cancelled
fix: exe 가 켜지자마자 ImportError 로 죽던 것
포터블 exe 를 실행하면 창 대신 오류창만 떴다.

  File "__main__.py", line 1, in <module>
  ImportError: attempted relative import with no known parent package

PyInstaller 는 진입 스크립트를 **패키지 없이** 최상위로 실행한다
(__package__ 가 비어 있다). 그래서 src/livesub/__main__.py 첫 줄의
`from .app import main` 이 그 자리에서 죽는다. 절대 임포트로 바꾼다.
`python -m livesub` 는 둘 다 동작하므로 잃는 것이 없다.

왜 못 잡았나 — exe 점검이 "8초 뒤에도 프로세스가 살아 있으면 통과" 였다.
PyInstaller 오류창은 프로세스를 그대로 띄워둔 채 서 있으므로 통과로
보고됐다. 살아 있는 것은 떴다는 뜻이 아니다. 고친다:

- 프로세스 생존이 아니라 **실제 창**을 본다. onefile 은 자식 프로세스가
  창을 띄우므로 프로세스 트리 전체를 훑는다.
- "Unhandled exception" / "Failed to execute script" 오류창을 보면
  그 자리에서 실패로 판정한다.
- APPDATA 를 빈 폴더로 돌려 첫 실행(저장 위치 화면)과 두 번째 실행
  (본 화면)을 각각 확인한다. 둘 다 실제 창 제목으로 확인한다.
- 같은 회귀를 리눅스에서도 잡도록 runpy 로 패키지 없는 실행을 재현하는
  테스트를 추가한다. 되돌려보면 실제로 실패하는 것을 확인했다.
2026-09-28 18:06:09 +09:00

214 lines
8.7 KiB
Python

"""포터블 빌드(= 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
# --- 티어 게이트가 실제 제약을 보는지 -------------------------------------
#
# 예전에는 torch 유무만 봤다. 그래서 8GB 카드에서도 "극한(16GB 필요) 사용
# 가능"이라고 답했고, 사용자가 고르면 로드에서 죽었다. 실제 GPU 로 검증하다
# 발견한 버그다.
def _gpu(total_gb: float):
from livesub.models.manager import GpuInfo
return GpuInfo(available=True, name="test", total_vram_mb=int(total_gb * 1024))
@pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다")
@pytest.mark.parametrize("key", ["precision", "ultimate"])
def test_vram_이_모자라면_티어를_막는다(key, monkeypatch):
monkeypatch.setattr("livesub.models.manager.has_awq", lambda: True)
ok, reason = tier_availability(TIERS[key], _gpu(7.5))
assert not ok
assert "VRAM" in reason and "7.5GB" in reason
@pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다")
def test_vram_이_충분하면_통과한다(monkeypatch):
monkeypatch.setattr("livesub.models.manager.has_awq", lambda: True)
assert tier_availability(TIERS["ultimate"], _gpu(24))[0]
@pytest.mark.skipif(not has_torch(), reason="torch 없으면 어차피 앞단에서 막힌다")
def test_autoawq_가_없으면_int4_티어를_막는다(monkeypatch):
monkeypatch.setattr("livesub.models.manager.has_awq", lambda: False)
ok, reason = tier_availability(TIERS["precision"], _gpu(24))
assert not ok
assert "autoawq" in reason
def test_ct2_티어는_gpu_조회_없이도_통과한다():
"""1~3티어는 torch 도 GPU 도 안 따진다 — 포터블에서 돌아야 하므로."""
def _boom():
raise AssertionError("CT2 티어는 detect_gpu 를 부르면 안 된다")
import livesub.models.manager as mgr
original, mgr.detect_gpu = mgr.detect_gpu, _boom
try:
for key in ("lightning", "swift", "balance"):
assert tier_availability(TIERS[key])[0]
finally:
mgr.detect_gpu = original
def test_진입_스크립트는_패키지_없이도_임포트된다():
"""PyInstaller 는 진입 스크립트를 **패키지 없이** 최상위로 실행한다.
그래서 그 파일에 상대 임포트가 하나라도 있으면 exe 가 켜지자마자
`attempted relative import with no known parent package` 로 죽는다.
실제로 그 상태의 exe 가 릴리스까지 나갔고, "8초 뒤에도 살아 있으면 통과"
였던 점검은 오류창이 떠 있는 것을 정상으로 봤다.
`runpy.run_path` 가 바로 그 조건(패키지 문맥 없음)을 만들어준다.
run_name 을 `__main__` 이 아닌 것으로 주면 GUI 는 뜨지 않고 임포트만 돈다.
"""
import runpy
root = SPEC.resolve().parents[1]
entry = root / "src" / "livesub" / "__main__.py"
# spec 이 실제로 이 파일을 진입점으로 쓰는지 확인한다. 아니면 엉뚱한 파일을
# 지키고 있는 셈이 된다.
assert '"livesub" / "__main__.py"' in SPEC.read_text(encoding="utf-8")
runpy.run_path(str(entry), run_name="__livesub_frozen_probe__")