Compare commits
3 Commits
v0.3.0-tes
...
v0.3.0-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec5e285bd | ||
|
|
7f678fba7b | ||
|
|
628280b227 |
@@ -14,9 +14,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -393,6 +397,135 @@ def run_gpu_checks() -> None:
|
||||
# --- 빌드된 exe 점검 --------------------------------------------------------
|
||||
|
||||
|
||||
#: PyInstaller 가 진입 스크립트에서 예외가 났을 때 띄우는 창.
|
||||
_CRASH_MARKERS = ("unhandled exception", "failed to execute script")
|
||||
|
||||
|
||||
def _process_tree(pid: int) -> set[int]:
|
||||
"""onefile exe 는 압축을 푼 뒤 자식 프로세스로 진짜 앱을 띄운다.
|
||||
|
||||
그래서 창의 주인은 우리가 Popen 한 pid 가 아니라 그 자식이다. 부모만 보면
|
||||
창을 영영 못 찾는다.
|
||||
"""
|
||||
pids = {pid}
|
||||
try:
|
||||
import psutil
|
||||
|
||||
parent = psutil.Process(pid)
|
||||
pids.update(child.pid for child in parent.children(recursive=True))
|
||||
except Exception: # noqa: BLE001 - psutil 이 없어도 부모만으로 계속한다
|
||||
pass
|
||||
return pids
|
||||
|
||||
|
||||
def _visible_windows(pids: set[int]) -> list[str]:
|
||||
"""그 프로세스들이 띄운 보이는 최상위 창의 제목."""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
titles: list[str] = []
|
||||
|
||||
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
||||
def _collect(hwnd, _lparam):
|
||||
owner = wintypes.DWORD()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner))
|
||||
if owner.value in pids and user32.IsWindowVisible(hwnd):
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
buf = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, buf, length + 1)
|
||||
if buf.value:
|
||||
titles.append(buf.value)
|
||||
return True
|
||||
|
||||
user32.EnumWindows(_collect, 0)
|
||||
return titles
|
||||
|
||||
|
||||
def _terminate_tree(proc: subprocess.Popen) -> None:
|
||||
"""부모만 죽이면 안 된다.
|
||||
|
||||
onefile 의 부모는 압축을 푸는 부트로더일 뿐이고, 창과 로그 파일을 쥐고 있는
|
||||
것은 자식이다. 부모만 terminate 하면 자식이 살아남아 로그 파일을 계속
|
||||
잡고 있고, 임시 폴더를 지울 수 없어 점검이 엉뚱한 곳에서 실패한다.
|
||||
"""
|
||||
children = []
|
||||
try:
|
||||
import psutil
|
||||
|
||||
children = psutil.Process(proc.pid).children(recursive=True)
|
||||
except Exception: # noqa: BLE001 - psutil 이 없으면 부모만이라도 정리한다
|
||||
pass
|
||||
|
||||
for child in children:
|
||||
with contextlib.suppress(Exception):
|
||||
child.terminate()
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
for child in children:
|
||||
with contextlib.suppress(Exception):
|
||||
child.wait(timeout=10)
|
||||
with contextlib.suppress(Exception):
|
||||
if child.is_running():
|
||||
child.kill()
|
||||
|
||||
|
||||
def _launch_and_wait_for_window(
|
||||
exe: Path, appdata: Path, require: str, timeout_s: int = 90
|
||||
) -> str:
|
||||
"""exe 를 띄우고 **기다리던 창이 뜰 때까지** 기다린다.
|
||||
|
||||
예전에는 "8초 뒤에도 프로세스가 살아 있으면 통과" 였다. 그런데 진입
|
||||
스크립트가 터지면 PyInstaller 가 오류창을 띄우고 그대로 서 있는다 —
|
||||
프로세스는 멀쩡히 살아 있으므로 통과로 보고됐다. 실제로 그 상태의 exe 가
|
||||
릴리스까지 나갔다. 살아 있다는 것은 떴다는 뜻이 아니다.
|
||||
|
||||
"아무 창이나 뜨면 통과" 도 부족하다. 자막 오버레이는 제목이 앱 이름
|
||||
그대로라, 본 화면이 안 떠도 그게 잡혀 통과해 버린다. 그래서 어떤 창을
|
||||
기다리는지(`require`)를 호출하는 쪽이 지정한다.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
env["APPDATA"] = str(appdata)
|
||||
proc = subprocess.Popen(
|
||||
[str(exe)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:]
|
||||
raise RuntimeError(f"창이 뜨기 전에 종료 (코드 {proc.returncode}): {err}")
|
||||
|
||||
titles = _visible_windows(_process_tree(proc.pid))
|
||||
for title in titles:
|
||||
if any(marker in title.lower() for marker in _CRASH_MARKERS):
|
||||
raise RuntimeError(f"오류창이 떴습니다: {title!r}")
|
||||
if any(require in title for title in titles):
|
||||
return " / ".join(titles)
|
||||
time.sleep(1)
|
||||
raise RuntimeError(f"{timeout_s}초 안에 {require!r} 창이 뜨지 않았습니다")
|
||||
finally:
|
||||
_terminate_tree(proc)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _scratch_appdata():
|
||||
"""점검용 %APPDATA%.
|
||||
|
||||
치울 때 실패해도 점검을 실패시키지 않는다. Windows 는 방금 죽인 프로세스가
|
||||
파일 핸들을 놓는 데 잠깐 시간이 걸려서, 임시 폴더 삭제가 PermissionError 로
|
||||
튀는 일이 있다. 그건 exe 의 문제가 아니라 치우는 쪽 사정이다.
|
||||
"""
|
||||
tmp = Path(tempfile.mkdtemp(prefix="livesub-smoke-"))
|
||||
try:
|
||||
yield tmp
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def run_exe_check(exe: Path) -> None:
|
||||
print(f"\n=== 빌드된 exe 점검: {exe} ===")
|
||||
|
||||
@@ -405,21 +538,37 @@ def run_exe_check(exe: Path) -> None:
|
||||
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
|
||||
return f"{mb:.1f} MB"
|
||||
|
||||
@check("exe 실행 후 8초 생존")
|
||||
def _launch():
|
||||
# 창을 띄우고 잠깐 살아 있는지 본다. 바로 죽으면 의존성 누락이다.
|
||||
proc = subprocess.Popen([str(exe)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
time.sleep(8)
|
||||
alive = proc.poll() is None
|
||||
if not alive:
|
||||
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:]
|
||||
raise RuntimeError(f"즉시 종료 (코드 {proc.returncode}): {err}")
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
return "정상 기동"
|
||||
@check("exe 첫 실행 — 저장 위치 화면")
|
||||
def _first_run():
|
||||
# APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다.
|
||||
with _scratch_appdata() as tmp:
|
||||
titles = _launch_and_wait_for_window(exe, appdata=tmp, require="저장 위치")
|
||||
return f"창 {titles!r}"
|
||||
|
||||
@check("exe 두 번째 실행 — 본 화면")
|
||||
def _second_run():
|
||||
# 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다.
|
||||
with _scratch_appdata() as tmp:
|
||||
data = tmp / "data"
|
||||
data.mkdir()
|
||||
pointer = tmp / "LiveSub"
|
||||
pointer.mkdir()
|
||||
(pointer / "location.json").write_text(
|
||||
json.dumps({"data_dir": str(data)}), encoding="utf-8"
|
||||
)
|
||||
# 모델 미리 올리기를 꺼둔다. 켜두면 창을 확인하러 온 점검이
|
||||
# 수 GB 짜리 모델 내려받기를 시작했다가 중간에 죽인다.
|
||||
(data / "config.json").write_text(
|
||||
json.dumps({"models": {"preload_on_start": False}}), encoding="utf-8"
|
||||
)
|
||||
# 자막 오버레이는 제목이 앱 이름뿐이라 그것만으로는 본 화면이 떴다는
|
||||
# 증거가 못 된다. 본 화면 제목에만 있는 표어를 기다린다.
|
||||
from livesub.constants import APP_SLOGAN
|
||||
|
||||
titles = _launch_and_wait_for_window(exe, appdata=tmp, require=APP_SLOGAN)
|
||||
if any("저장 위치" in t for t in titles.split(" / ")):
|
||||
raise RuntimeError(f"위치를 이미 골랐는데 또 물어봤습니다: {titles!r}")
|
||||
return f"창 {titles!r}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
from .app import main
|
||||
"""`python -m livesub` 와 포터블 exe 의 진입점.
|
||||
|
||||
**여기서는 상대 임포트를 쓰면 안 된다.** PyInstaller 는 이 파일을 패키지 없이
|
||||
최상위 스크립트로 실행한다(`__package__` 가 비어 있다). 그래서 `from .app import`
|
||||
는 그 자리에서 `attempted relative import with no known parent package` 로 죽는다.
|
||||
exe 가 켜지자마자 오류창만 띄우던 원인이 이것이었다.
|
||||
|
||||
`python -m livesub` 는 어느 형태든 동작하므로 둘 다 되는 절대 임포트로 둔다.
|
||||
"""
|
||||
|
||||
from livesub.app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -190,3 +190,24 @@ def test_ct2_티어는_gpu_조회_없이도_통과한다():
|
||||
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__")
|
||||
|
||||
Reference in New Issue
Block a user