import sys import os import glob import shutil import subprocess import logging import time from tqdm import tqdm import requests from cx_Freeze import setup, Executable from setuptools import find_packages from setuptools.command.build_ext import build_ext from cx_Freeze.command.build_exe import build_exe as _build_exe from setuptools import setup as cy_setup, Extension from Cython.Build import cythonize # 버전 및 메타데이터 가져오기 from updater.__version__ import ( __title__, __version__, __description__, __author__, __author_email__, __license__, __install_requires__ ) # ============================================================================ # [Configuration] 0. Gokapi 서버 설정 (업로드를 위해 필수) # ============================================================================ GOKAPI_URL = "https://go.wrmc.cc" # 사용자님의 Gokapi 주소로 변경하세요 GOKAPI_APIKEY = "Pncy6s7pyi61rtUsV3kUwfPtOvU93I " # Gokapi 관리자 페이지에서 발급받은 API 키 # ============================================================================ # [Configuration] 설정 및 상수 정의 # ============================================================================ # 1. Cython으로 컴파일할 모듈 목록 (최소화하여 테스트) CYTHON_MODULES = [ # 최소한의 모듈만 테스트 "loggerModule", # "modules/image_worker", # "modules/tray_app", ] # 2. 빌드 후 제거할 불필요한 파일/폴더 목록 CLEANUP_TARGETS = [ # 디렉토리 "lib/modules/ocr_backends", # OCR 백엔드들 (필요시 유지) "lib/modules/old_modules", # 구 버전 모듈들 "lib/modules/test", # 테스트 파일들 "lib/modules/debug_images", # 디버그 이미지들 "lib/modules/img", # 샘플 이미지들 "lib/modules/output", # 출력 샘플들 "lib/modules/outputs", # 출력 샘플들 "lib/modules/user_data", # 사용자 데이터 "lib/modules/PP_Models", # Paddle 모델들 (이미 포함될 수 있음) "lib/paddle", "lib/paddleocr", "lib/onnxruntime", "lib/skimage", "lib/scipy", # 파일 "lib/modules/migan_traced.pt", "lib/modules/*.pyc", "lib/modules/*/*.pyc", "lib/modules/*/*/*.pyc", ] # 3. 소스 패키징에서 강제로 제외할 패키지들 BASE_EXCLUDES = [ 'tkinter', 'PyQt4', 'PyQt5', 'AppKit', 'Foundation', 'IPython', 'OpenSSL', 'curses', 'test', 'matplotlib', 'asyncpg', 'importlib._bootstrap', 'importlib.machinery', 'pytest', 'hypothesis', 'mypy', 'coverage', 'tox', 'sympy', 'mpmath', 'gmpy2', 'paddle', 'paddleocr', 'paddlehub', 'onnxruntime', 'onnx', 'skimage', 'scikit-image', 'pyclipper', 'shapely', 'scipy', 'imgaug', 'albumentations', 'torch', 'tensorflow', 'keras', "PySide6", # 프로젝트 특정 제외 # 참고: CYTHON_MODULES에 포함된 모듈들은 get_cython_freeze_config()에서 자동으로 제외됨 ] # 4. 강제 포함 패키지 BASE_INCLUDES = [ # 정말 최소한의 모듈만 포함하여 테스트 'json', 'os', 'sys', 'time', 'threading', ] # ============================================================================ # [Helper Functions] 로직 분리 # ============================================================================ def run_setup_clean(): """ 기존 빌드찌꺼기 제거 """ print("\n>>> [Cleaner] 기존 빌드찌꺼기 제거 시작...") # 자동으로 'y' 입력하여 확인 절차 생략 result = subprocess.run(["python", "-c", "import sys; sys.path.insert(0, 'tests'); from setup_clean import clean_files_oswalk; clean_files_oswalk()"]) if result.returncode == 0: print(">>> [Cleaner] 기존 빌드찌꺼기 제거 완료!\n") else: print(">>> [Cleaner] 정리 중 오류 발생, 계속 진행합니다.\n") def run_cython_build(): """ setup_cython.py를 별도로 실행하지 않고, 여기서 직접 Cython 빌드를 수행합니다. (경로 꼬임 방지) """ print("\n>>> [Cython] 내부 빌드 프로세스 시작...") extensions = [] # 전역변수 CYTHON_MODULES 리스트를 사용 for file_path in CYTHON_MODULES: # 파일 확인 (.py가 없는 경우 붙여서 확인) source_file = file_path if not os.path.exists(source_file): if os.path.exists(source_file + ".py"): source_file += ".py" else: print(f" [Warning] 컴파일 대상 파일을 찾을 수 없음: {file_path}") continue # 모듈 이름 생성 (경로 -> 점 표기법) # 예: modules/image_worker -> modules.image_worker no_ext = os.path.splitext(source_file)[0] module_name = no_ext.replace("/", ".").replace("\\", ".") # Extension 객체 생성 ext = Extension( name=module_name, sources=[source_file] ) extensions.append(ext) if not extensions: print(">>> [Cython] 컴파일할 대상이 없습니다.") return # setuptools의 setup을 직접 호출 (인자값으로 build_ext --inplace 전달) try: cy_setup( name="CythonInternalBuild", ext_modules=cythonize( extensions, compiler_directives={'language_level': "3"}, quiet=True ), script_args=['build_ext', '--inplace'] ) print(">>> [Cython] 빌드 성공!\n") except Exception as e: print(f"\n>>> [Cython] 빌드 중 오류 발생: {e}") sys.exit(1) def get_cython_freeze_config(modules_list): """ Cython 모듈 리스트를 기반으로 cx_Freeze용 설정을 생성합니다. Returns: (excludes_list, include_files_list, include_packages_to_remove) """ excludes = [] # .py 소스 제외용 include_files = [] # .pyd 파일 포함용 module_names = set() for module_path in modules_list: # 모듈 이름 변환 (modules/image_worker -> modules.image_worker) clean_path = os.path.splitext(module_path)[0] module_name = clean_path.replace("/", ".").replace("\\", ".") module_names.add(module_name) excludes.append(module_name) # .pyd 파일 찾기 및 매핑 dir_name = os.path.dirname(module_path) base_name = os.path.basename(module_path) pyd_pattern = os.path.join(dir_name, f"{base_name}*.pyd") found_pyds = glob.glob(pyd_pattern) if found_pyds: src_pyd = found_pyds[0] # 라이브러리 구조 유지: lib/modules/... dest_pyd = os.path.join("lib", dir_name, f"{base_name}.pyd") include_files.append((src_pyd, dest_pyd)) print(f" [Protect] {module_name}: .py 제외, .pyd 포함") else: print(f" [Warning] .pyd 없음: {module_path}. (원본 .py가 포함될 수 있음)") return excludes, include_files, module_names def collect_include_files(): """DLL, 리소스 파일 등 기타 include_files 리스트를 생성합니다.""" # 디버그를 위해 최소한의 파일만 포함 files = [] # VC Runtime Files만 포함 (최소한으로) vc_runtimes = [ ('C:/Windows/System32/vcruntime140.dll', 'vcruntime140.dll'), ('C:/Windows/System32/msvcp140.dll', 'msvcp140.dll'), ] # VC 런타임 파일 존재 여부 확인 후 추가 for src, dest in vc_runtimes: if os.path.exists(src): files.append((src, dest)) print(f"DEBUG: Including {len(files)} files") return files # ============================================================================ # [Main Logic] 실행 로직 (빌드 시에만 실행) # ============================================================================ def prepare_build_options(): """빌드 옵션을 준비하는 함수""" global final_includes, final_excludes, final_include_files # 0. 기존 빌드찌꺼기 제거 run_setup_clean() # 1. Cython 빌드 실행 run_cython_build() # 2. cx_Freeze 설정 준비 cy_excludes, cy_include_files, cy_module_names = get_cython_freeze_config(CYTHON_MODULES) resource_include_files = collect_include_files() # 3. 최종 옵션 조합 final_includes = [mod for mod in BASE_INCLUDES if mod not in cy_module_names] # 충돌 방지 final_excludes = BASE_EXCLUDES + cy_excludes final_include_files = resource_include_files + cy_include_files # 디버그: 문제가 되는 모듈들을 임시로 제외 if EXCLUDE_PROBLEMATIC_MODULES and DEBUG_MODE: print("=== EXCLUDING PROBLEMATIC MODULES FOR DEBUG ===") # 문제가 될 수 있는 모듈들 임시 제외 problematic_modules = [ 'onnx_ocr_module', 'onnx_ocr_wrapper', 'onnx_ocr_module.src.onnx_ocr_wrapper', 'modules.onnx_ocr_module', 'modules.onnx_ocr_module.src.onnx_ocr_wrapper', 'paddle', 'paddleocr', 'onnxruntime', 'onnx' ] original_count = len(final_includes) final_includes = [mod for mod in final_includes if not any(pm in mod for pm in problematic_modules)] # include_files에서도 제외 final_include_files = [f for f in final_include_files if not any(pm in str(f) for pm in problematic_modules)] print(f"Excluded problematic modules. Includes: {original_count} -> {len(final_includes)}") print(f"Excluded problematic files. Include files: {len(resource_include_files + cy_include_files)} -> {len(final_include_files)}") print("=" * 50) # 디버그 모드 설정 DEBUG_MODE = True # 문제가 되는 큰 모듈들을 임시로 제외하여 테스트 EXCLUDE_PROBLEMATIC_MODULES = True # 0. 기존 빌드찌꺼기 제거 run_setup_clean() # 1. Cython 빌드 실행 run_cython_build() # 2. cx_Freeze 설정 준비 cy_excludes, cy_include_files, cy_module_names = get_cython_freeze_config(CYTHON_MODULES) resource_include_files = collect_include_files() # 3. 최종 옵션 조합 final_includes = [mod for mod in BASE_INCLUDES if mod not in cy_module_names] # 충돌 방지 final_excludes = BASE_EXCLUDES + cy_excludes final_include_files = resource_include_files + cy_include_files # 디버그: 문제가 되는 모듈들을 임시로 제외 if EXCLUDE_PROBLEMATIC_MODULES and DEBUG_MODE: print("=== EXCLUDING PROBLEMATIC MODULES FOR DEBUG ===") # 문제가 될 수 있는 모듈들 임시 제외 problematic_modules = [ 'onnx_ocr_module', 'onnx_ocr_wrapper', 'onnx_ocr_module.src.onnx_ocr_wrapper', 'modules.onnx_ocr_module', 'modules.onnx_ocr_module.src.onnx_ocr_wrapper', 'paddle', 'paddleocr', 'onnxruntime', 'onnx' ] original_count = len(final_includes) final_includes = [mod for mod in final_includes if not any(pm in mod for pm in problematic_modules)] # include_files에서도 제외 final_include_files = [f for f in final_include_files if not any(pm in str(f) for pm in problematic_modules)] print(f"Excluded problematic modules. Includes: {original_count} -> {len(final_includes)}") print(f"Excluded problematic files. Include files: {len(resource_include_files + cy_include_files)} -> {len(final_include_files)}") print("=" * 50) build_options = { 'packages': [ 'ctypes', 'asyncio', 'subprocess', 'pyperclip', 'numpy', 'requests', 'PIL', 'bs4', 'psutil', 'openai', 'httpx', 'pydantic', # fastapi, uvicorn 제외 (설치 안됨) 'pandas', 'supabase', 'translatepy', 'markdown', 'json', 'json.encoder', 'json.decoder', 'json.scanner', 'dotenv', 'pathlib', 'logging', 'threading', 'multiprocessing', # 'cv2', # OpenCV 제외 (DLL 문제) ], 'includes': final_includes, 'excludes': final_excludes, 'include_files': final_include_files, 'zip_include_packages': [], 'optimize': 0, 'silent': False if DEBUG_MODE else True, # 디버그 모드에서는 silent 해제 'include_msvcr': True, } # 디버그 모드 추가 설정 if DEBUG_MODE: print("=== DEBUG MODE ENABLED ===") print(f"Final includes count: {len(final_includes)}") print(f"Final excludes count: {len(final_excludes)}") print(f"Final include_files count: {len(final_include_files)}") # 문제가 될 수 있는 큰 모듈들을 임시로 제외해서 테스트 print("Checking for problematic modules...") # 큰 모듈들을 확인 large_modules = [m for m in final_includes if any(x in m for x in ['onnx', 'paddle', 'torch', 'tensorflow'])] if large_modules: print(f"Large modules detected: {large_modules}") # 포함 파일들 확인 print("Include files:") for f in final_include_files[:10]: # 처음 10개만 print(f" {f}") if len(final_include_files) > 10: print(f" ... and {len(final_include_files) - 10} more") print("=" * 50) # ============================================================================ # [Custom Build Class] 빌드 프로세스 커스터마이징 # ============================================================================ class CustomBuildExe(_build_exe): def run(self): # 전체 진행률: 100 # 초기값 10: Cython 빌드는 이 클래스 실행 전(if __name__...)에 이미 완료됨 print("\n" + "="*60) print(" ImgWorker 통합 빌드 시스템") print("="*60 + "\n") try: # 총 6단계로 구성 (Cython은 이미 완료됨 -> 10%) with tqdm(total=100, initial=10, unit="pct", bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt}% [{elapsed}]", ncols=80, colour='green') as pbar: # [Step 2] cx_Freeze 빌드 (10 -> 30%) pbar.set_description("Step 2/6: cx_Freeze Packaging") print(f"DEBUG: Starting cx_Freeze build with {len(build_options.get('includes', []))} includes") print(f"DEBUG: {len(build_options.get('excludes', []))} excludes") print(f"DEBUG: {len(build_options.get('include_files', []))} include_files") print(f"DEBUG: {len(build_options.get('packages', []))} packages") # cx_Freeze 실행 전 타임스탬프 import time start_time = time.time() print(f"DEBUG: Build start time: {start_time}") _build_exe.run(self) end_time = time.time() print(f"DEBUG: Build end time: {end_time}") print(f"DEBUG: Build duration: {end_time - start_time:.2f} seconds") pbar.update(20) # [Step 3] 빌드 결과물 정리 (30 -> 35%) pbar.set_description("Step 3/6: Cleaning Build Output") self._cleanup_build_output() pbar.update(5) # [Step 4] Inno Setup 컴파일 (35 -> 70%) pbar.set_description("Step 4/6: Inno Setup Compiling") self._run_inno_setup() # .iss 생성 self._compile_inno_installer() # .exe 생성 pbar.update(35) # [Step 5] 파일명 변경 (버전 정보 추가) (70 -> 75%) pbar.set_description("Step 5/6: Renaming Installer") final_installer_path = self._rename_installer_with_version() pbar.update(5) # [Step 6] Gokapi 업로드 (75 -> 95%) if final_installer_path and GOKAPI_URL != "https://gokapi.your-domain.com": pbar.set_description("Step 6/6: Uploading to Gokapi") self._upload_to_gokapi(final_installer_path) else: print("\n[Skip] Gokapi 설정이 없거나 파일이 없어 업로드를 건너뜁니다.") pbar.update(20) # [Final] 소스 정리 (95 -> 100%) pbar.set_description("Finalizing: Cleaning Source") self._cleanup_cython_artifacts() pbar.update(5) pbar.set_description("Build Complete!") except KeyboardInterrupt: print("\n\n>>> 빌드가 취소되었습니다.") sys.exit(1) except Exception as e: print(f"\n\n>>> 빌드 중 치명적인 오류 발생: {e}") # 에러 상세 내용을 보기 위해 traceback 출력 import traceback traceback.print_exc() sys.exit(1) def _get_dir_size(self, path): total = 0 for dirpath, _, filenames in os.walk(path): for f in filenames: total += os.path.getsize(os.path.join(dirpath, f)) return total def _rename_installer_with_version(self): """생성된 설치 파일 이름에 버전을 추가합니다.""" # Inno Setup의 기본 출력 경로 (dist/installer) dist_dir = os.path.join(os.path.dirname(__file__), "dist", "installer") # 원래 생성되는 파일명 (generate_iss.py 설정에 따름, 보통 Setup.exe) # 로그에 찍힌 이름을 기준으로 찾습니다. original_name = "ImgWorker Setup.exe" original_path = os.path.join(dist_dir, original_name) if not os.path.exists(original_path): print(f"\n[Error] 원본 설치 파일을 찾을 수 없습니다: {original_path}") # 혹시 다른 이름일 수도 있으니 exe 파일을 검색해봅니다. exe_files = glob.glob(os.path.join(dist_dir, "*.exe")) if exe_files: original_path = max(exe_files, key=os.path.getctime) # 가장 최신 파일 else: return None # 새 파일명: ImgWorker Setup_V3.12.15.exe new_name = f"ImgWorker Setup_V{__version__}.exe" new_path = os.path.join(dist_dir, new_name) try: # 기존에 같은 버전 파일이 있으면 삭제 if os.path.exists(new_path): os.remove(new_path) os.rename(original_path, new_path) print(f"\n[Rename] 파일명이 변경되었습니다:\n -> {new_name}") return new_path except Exception as e: print(f"\n[Error] 파일명 변경 실패: {e}") return original_path def _upload_to_gokapi(self, file_path): """Gokapi 서버로 파일을 업로드합니다.""" print("\n[Upload] Gokapi 서버로 업로드를 시작합니다...") if not os.path.exists(file_path): print(" [Error] 업로드할 파일이 없습니다.") return try: url = f"{GOKAPI_URL}/api/v1/upload" headers = { "Authorization": f"Bearer {GOKAPI_APIKEY}" } with open(file_path, "rb") as f: # 멀티파트 업로드 files = {"file": (os.path.basename(file_path), f)} data = { "expire": 0 # ✅ 영구 저장 } # 만료일 설정 (예: 0=무제한, 7d=7일) - 필요시 data={'expiry': '7d'} 추가 response = requests.post( url, headers=headers, files=files, data=data, timeout=30 ) if response.status_code in (200, 201): data = response.json() # Gokapi 응답 구조 hotlink = data.get("DownloadUrl") or data.get("File", {}).get("Url") print("\n" + "#" * 50) print("업로드 성공!") print(f"다운로드 링크: {hotlink}") print("#" * 50 + "\n") else: print(f"\n[Error] 업로드 실패 (Status: {response.status_code})") print("Server Response:", response.text) except Exception as e: print(f"\n[Error] 업로드 중 예외 발생: {e}") def _cleanup_build_output(self): """빌드된 exe 폴더 내부의 불필요한 파일 삭제""" print("\n[Cleaner] 빌드 결과물 정리 중...") build_dir = os.path.join(os.path.dirname(__file__), "build") if not os.path.exists(build_dir): return removed_count = 0 total_saved = 0 for item in os.listdir(build_dir): if not item.startswith("exe."): continue exe_dir = os.path.join(build_dir, item) for unnecessary in CLEANUP_TARGETS: target_path = os.path.join(exe_dir, unnecessary) try: if os.path.isdir(target_path): size = self._get_dir_size(target_path) shutil.rmtree(target_path) total_saved += size print(f" ✓ 디렉토리 삭제: {unnecessary}") elif os.path.isfile(target_path): size = os.path.getsize(target_path) os.remove(target_path) total_saved += size print(f" ✓ 파일 삭제: {unnecessary}") removed_count += 1 except Exception: pass # 없는 파일은 무시 print(f"[Cleaner] 정리 완료: {total_saved / (1024*1024):.1f} MB 절약\n") def _cleanup_cython_artifacts(self): """소스 폴더의 .pyd, .c 임시 파일 삭제""" print("[Cleaner] 소스 폴더 Cython 임시 파일 정리 중...") deleted = 0 for module_path in CYTHON_MODULES: dir_name = os.path.dirname(module_path) base_name = os.path.basename(module_path) patterns = [ os.path.join(dir_name, f"{base_name}*.pyd"), os.path.join(dir_name, f"{base_name}.c") ] for pattern in patterns: for f in glob.glob(pattern): try: os.remove(f) deleted += 1 except Exception: pass print(f"[Cleaner] 소스 정리 완료: {deleted}개 파일 삭제\n") def _run_inno_setup(self): """Inno Setup 스크립트 생성기 실행 (현재 프로젝트에는 없으므로 생략)""" print("[Inno Setup] 설치 스크립트 생성 생략 (generate_iss.py 없음)...") def _compile_inno_installer(self): """Inno Setup 컴파일 생략 (현재 프로젝트에는 설치 스크립트 없음)""" print("[Inno Setup] 설치 파일 컴파일 생략...") # ============================================================================ # [Setup] 최종 실행 # ============================================================================ # 디버그용 간단한 테스트 함수 def test_build(): """빌드 전 문제점을 미리 테스트""" print("=== BUILD TEST ===") # 1. 모듈 import 테스트 print("Testing module imports...") test_modules = ['fastapi', 'uvicorn', 'pydantic', 'cv2', 'numpy', 'PIL'] for mod in test_modules: try: __import__(mod) print(f" OK {mod}") except ImportError as e: print(f" FAIL {mod}: {e}") # 2. 큰 파일들 확인 print("\nChecking large files...") large_files = [] for root, dirs, dirs[:] in os.walk('.'): for file in dirs: if file in ['onnx_ocr_module', 'PP_Models', 'rembg_models', 'migan_onnx']: path = os.path.join(root, file) try: size = sum(os.path.getsize(os.path.join(dirpath, f)) for dirpath, _, files in os.walk(path) for f in files) large_files.append((path, size)) except: pass for path, size in sorted(large_files, key=lambda x: x[1], reverse=True): print(f" {path}: {size / (1024*1024):.1f} MB") print("=" * 50) base = 'Win32GUI' if sys.platform == 'win32' else None # 명령행 인자 처리 if __name__ == "__main__": import sys if len(sys.argv) > 1 and sys.argv[1] == 'test': test_build() sys.exit(0) # 빌드 옵션 준비 (빌드 시에만) prepare_build_options() setup( name=__title__, version=__version__, description=__description__, author=__author__, author_email=__author_email__, license=__license__, packages=find_packages(), install_requires=__install_requires__, python_requires='>=3.11', include_package_data=True, zip_safe=False, options={'build_exe': build_options}, executables=[ Executable( 'main.py', base=base, target_name='ImgWorker.exe' # icon="Edit_PartTimer3.ico" # 아이콘 파일이 없으므로 기본 아이콘 사용 ) ], cmdclass={ "build_exe": CustomBuildExe, }, )