# -*- mode: python ; coding: utf-8 -*- """PyInstaller spec — onefile portable (ejecutar en Windows o Linux según destino).""" import re import sys from pathlib import Path block_cipher = None ROOT = Path(SPECPATH).resolve().parent platform = "windows" if sys.platform == "win32" else "linux" bundled = ROOT / "packaging" / "bundled" / platform def _read_app_version() -> str: """ Lee __version__ de app/__init__.py sin importar el paquete: el spec corre en el intérprete de PyInstaller y no debe cargar las dependencias de la app. """ text = (ROOT / "app" / "__init__.py").read_text(encoding="utf-8") match = re.search(r'^__version__\s*=\s*["\']([^"\']+)["\']', text, re.MULTILINE) if not match: raise SystemExit("No se pudo leer __version__ de app/__init__.py") return match.group(1) APP_VERSION = _read_app_version() _assets = ROOT / "packaging" / "assets" datas = [ (str(ROOT / "packaging" / "templates" / "env.default"), "packaging/templates"), # bundled-versions.json va embebido para que el bootstrap sepa qué versiones de # 7-Zip/ODBC trae este build y re-despliegue config/ cuando cambien. (str(ROOT / "packaging" / "bundled-versions.json"), "packaging"), ] if _assets.is_dir(): for _icon in _assets.iterdir(): if _icon.is_file() and _icon.suffix.lower() in (".png", ".ico"): datas.append((str(_icon), "packaging/assets")) binaries = [] seven_dir = bundled / "7zip" if seven_dir.is_dir(): for item in seven_dir.iterdir(): if item.is_file(): binaries.append((str(item), "bundled/7zip")) odbc_dir = bundled / "odbc" if odbc_dir.is_dir(): for item in odbc_dir.rglob("*"): if not item.is_file(): continue if item.suffix.lower() in (".msi", ".deb"): continue rel = item.relative_to(odbc_dir) dest = f"bundled/odbc/{rel.parent}".replace("\\", "/") if str(rel.parent) == ".": dest = "bundled/odbc" binaries.append((str(item), dest)) # Libs de sistema Qt (cluster xcb/X11 + EGL) que el plugin xcb carga por dlopen. # Van JUNTO a las librerías Qt (PySide6/Qt/lib): libQt6XcbQpa.so.6 tiene RPATH # $ORIGIN, así que resuelve libxcb-cursor.so.0 y el resto en su propio directorio # sin depender de LD_LIBRARY_PATH. (El stack ODBC va con el driver en bundled/odbc, # con RPATH=$ORIGIN aplicado por download-bundled-deps.sh.) Solo aplica en Linux. qt_sys_lib = bundled / "qt" / "lib" if qt_sys_lib.is_dir(): for item in qt_sys_lib.iterdir(): if item.is_file() and not item.is_symlink(): binaries.append((str(item), "PySide6/Qt/lib")) hiddenimports = [ "PySide6", "pyodbc", "requests", "paramiko", "dotenv", "cryptography.fernet", ] if platform == "windows": hiddenimports.append("win32crypt") from PyInstaller.utils.hooks import collect_all pyside_datas, pyside_binaries, pyside_hidden = collect_all("PySide6") datas += pyside_datas binaries += pyside_binaries hiddenimports += pyside_hidden a = Analysis( [str(ROOT / "runner.py")], pathex=[str(ROOT)], binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False, ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) # Icono del ejecutable (solo aplica en Windows; en Linux se ignora sin error). _icon_file = ROOT / "packaging" / "assets" / "tray-icon.ico" _icon = str(_icon_file) if _icon_file.is_file() else None def _version_quad(version: str) -> tuple: """VSVersionInfo exige exactamente 4 enteros; se rellena o recorta lo que haga falta.""" parts = [] for chunk in version.split(".")[:4]: digits = "".join(c for c in chunk if c.isdigit()) parts.append(int(digits) if digits else 0) while len(parts) < 4: parts.append(0) return tuple(parts) def _version_info_dir(): """ Directorio donde escribir el recurso de versión. Se prefiere `workpath`, el directorio de trabajo que PyInstaller inyecta en el namespace del spec, por dos razones concretas de este proyecto: 1. build.ps1 lo redirige a %LOCALAPPDATA%\\Temp cuando el repo está en una ruta UNC (\\\\wsl.localhost\\...), que es el caso al compilar Windows desde WSL. 2. `build/` en la raíz del repo lo crea el build de Linux DENTRO DE DOCKER, así que queda propiedad de root; el Python de Windows escribe por SMB como el usuario normal y recibe PermissionError. """ candidate = globals().get("workpath") if candidate: return Path(candidate) return ROOT / "build" def _write_version_info(version: str) -> str: """ Genera el recurso de versión de Windows. Sin esto el .exe sale sin metadatos y las Propiedades del archivo no muestran nada, lo que complica auditar qué versión está instalada en un servidor. """ quad = _version_quad(version) out = _version_info_dir() / "version_info.txt" out.parent.mkdir(parents=True, exist_ok=True) out.write_text( f"""VSVersionInfo( ffi=FixedFileInfo( filevers={quad}, prodvers={quad}, mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0, date=(0, 0), ), kids=[ StringFileInfo([ StringTable('080904B0', [ StringStruct('CompanyName', 'Aduanasoft'), StringStruct('FileDescription', 'CloudRestoreAS - Restauracion automatica SQL Server'), StringStruct('FileVersion', '{version}'), StringStruct('InternalName', 'CloudRestoreAS'), StringStruct('LegalCopyright', 'Aduanasoft'), StringStruct('OriginalFilename', 'CloudRestoreAS.exe'), StringStruct('ProductName', 'CloudRestoreAS'), StringStruct('ProductVersion', '{version}'), ]) ]), VarFileInfo([VarStruct('Translation', [0x0809, 1200])]), ], ) """, encoding="utf-8", ) return str(out) # El recurso de versión solo existe en PE/Windows; en Linux PyInstaller lo ignoraría. _version_info = _write_version_info(APP_VERSION) if platform == "windows" else None exe = EXE( pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name="CloudRestoreAS", icon=_icon, version=_version_info, debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, )