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
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -393,6 +397,135 @@ def run_gpu_checks() -> None:
|
|||||||
# --- 빌드된 exe 점검 --------------------------------------------------------
|
# --- 빌드된 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:
|
def run_exe_check(exe: Path) -> None:
|
||||||
print(f"\n=== 빌드된 exe 점검: {exe} ===")
|
print(f"\n=== 빌드된 exe 점검: {exe} ===")
|
||||||
|
|
||||||
@@ -405,21 +538,37 @@ def run_exe_check(exe: Path) -> None:
|
|||||||
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
|
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
|
||||||
return f"{mb:.1f} MB"
|
return f"{mb:.1f} MB"
|
||||||
|
|
||||||
@check("exe 실행 후 8초 생존")
|
@check("exe 첫 실행 — 저장 위치 화면")
|
||||||
def _launch():
|
def _first_run():
|
||||||
# 창을 띄우고 잠깐 살아 있는지 본다. 바로 죽으면 의존성 누락이다.
|
# APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다.
|
||||||
proc = subprocess.Popen([str(exe)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
with _scratch_appdata() as tmp:
|
||||||
time.sleep(8)
|
titles = _launch_and_wait_for_window(exe, appdata=tmp, require="저장 위치")
|
||||||
alive = proc.poll() is None
|
return f"창 {titles!r}"
|
||||||
if not alive:
|
|
||||||
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:]
|
@check("exe 두 번째 실행 — 본 화면")
|
||||||
raise RuntimeError(f"즉시 종료 (코드 {proc.returncode}): {err}")
|
def _second_run():
|
||||||
proc.terminate()
|
# 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다.
|
||||||
try:
|
with _scratch_appdata() as tmp:
|
||||||
proc.wait(timeout=10)
|
data = tmp / "data"
|
||||||
except subprocess.TimeoutExpired:
|
data.mkdir()
|
||||||
proc.kill()
|
pointer = tmp / "LiveSub"
|
||||||
return "정상 기동"
|
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:
|
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__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
@@ -190,3 +190,24 @@ def test_ct2_티어는_gpu_조회_없이도_통과한다():
|
|||||||
assert tier_availability(TIERS[key])[0]
|
assert tier_availability(TIERS[key])[0]
|
||||||
finally:
|
finally:
|
||||||
mgr.detect_gpu = original
|
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