# -*- mode: python ; coding: utf-8 -*- """PyInstaller onedir spec for Orisys Studio (Windows).""" from __future__ import annotations import json import os from pathlib import Path from PyInstaller.building.api import COLLECT, EXE, PYZ from PyInstaller.building.build_main import Analysis from PyInstaller.utils.hooks import collect_all, collect_data_files SPEC_DIR = Path(SPECPATH).resolve() REPO_ROOT = SPEC_DIR.parents[1] BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding" ENTRY = SPEC_DIR / "entry.py" #: What ``setup.ps1`` recorded about this machine (interpreter, and where its runtime DLLs #: live when that interpreter is not a python.org one). BUILD_ENV_STAMP = REPO_ROOT / ".venv-build" / "orisys-build-env.json" #: The runtime DLLs an Anaconda interpreter keeps in ``\Library\bin`` or #: ``\DLLs``. PyInstaller does not search those directories, so a frozen app built #: without them dies before it can log anything: #: ``ImportError: DLL load failed while importing pyexpat`` (libexpat) #: ``ImportError: DLL load failed while importing _ctypes`` (ffi-8) #: Building with python.org CPython avoids the whole list; when ``setup.ps1`` accepts a conda #: interpreter it records the directory, and this is where that decision is honoured. _RUNTIME_DLLS = ( "libexpat.dll", "expat.dll", "ffi-8.dll", "ffi-7.dll", "ffi.dll", "libssl-3-x64.dll", "libcrypto-3-x64.dll", "sqlite3.dll", "liblzma.dll", "libbz2.dll", "zlib.dll", ) def _extra_runtime_binaries() -> list[tuple[str, str]]: """Runtime DLLs from a non-python.org interpreter, as ``(source, dest)`` pairs.""" folders: list[str] = [] try: stamp = json.loads(BUILD_ENV_STAMP.read_text(encoding="utf-8")) folders.extend(str(item) for item in (stamp.get("extra_dll_dirs") or ())) except Exception: # No stamp (a spec run by hand): fall back to the environment, if it says it is conda. prefix = os.environ.get("CONDA_PREFIX") if prefix: folders.append(os.path.join(prefix, "Library", "bin")) out: list[tuple[str, str]] = [] for folder in folders: for name in _RUNTIME_DLLS: candidate = Path(folder) / name if candidate.is_file(): out.append((str(candidate), ".")) return out def _branding_datas() -> list[tuple[str, str]]: datas: list[tuple[str, str]] = [] if not BRANDING_DIR.is_dir(): return datas for path in BRANDING_DIR.rglob("*"): if path.is_file(): datas.append((str(path), "branding")) return datas #: Source-control and tool-cache names that must never ship. ``collect_data_files`` copies #: *every* non-Python file a package contains, and the SDK keeps a tracked #: ``src/orisys/.gitignore`` — which is how ``_internal\orisys\.gitignore`` reached the #: bundle. The list is deliberately narrow: a real asset (``theme/*.json``, cv2's own #: ``config.py``) must survive, so only VCS/cache names are dropped. _RESIDUE_NAMES = frozenset( { ".git", ".gitignore", ".gitattributes", ".gitmodules", ".gitkeep", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".scratch", ".tox", ".idea", ".vscode", } ) _RESIDUE_SUFFIXES = (".pyc", ".pyo", ".orig", ".rej", "~") def _without_residue(datas: list[tuple[str, str]]) -> list[tuple[str, str]]: """Drop VCS/cache residue from collected package data, naming what was dropped.""" kept: list[tuple[str, str]] = [] dropped: list[str] = [] for source, dest in datas: parts = Path(source).parts if any(part in _RESIDUE_NAMES for part in parts) or source.endswith(_RESIDUE_SUFFIXES): dropped.append(source) continue kept.append((source, dest)) for item in dropped: print(f"[spec] dropped residue from package data: {item}") return kept block_cipher = None # Config is copied beside the EXE by build.ps1 (writable / editable). # Branding + package data go into _internal via Analysis datas. datas = _branding_datas() binaries: list = _extra_runtime_binaries() hiddenimports = [ "cv2", # PySide6's runtime is shiboken6; the Qt binding itself is found from the imports, but # QtOpenGLWidgets is unusual enough to name explicitly: it is a separate module and DLL # (Qt6OpenGLWidgets), and without it the 3D pane reports "no OpenGL widgets" while the # rest of the app looks fine. "shiboken6", "PySide6.QtOpenGLWidgets", "pyqtgraph.graphicsItems", "scipy.signal", "numba", ] datas += collect_data_files("orisys_app_shared", includes=["theme/*.json"]) try: datas += collect_data_files("orisys") except Exception as exc: # Tolerated so a spec run by hand still works, but never silently: a package whose data # cannot be collected loses assets, and that is invisible in the built EXE. print(f"[spec] WARNING: could not collect orisys package data: {exc}") try: cv2_datas, cv2_binaries, cv2_hidden = collect_all("cv2") datas += cv2_datas binaries += cv2_binaries hiddenimports += list(cv2_hidden) except Exception as exc: print(f"[spec] WARNING: could not collect cv2: {exc}") # Applied last, so it covers every contributor above (including a third-party wheel). datas = _without_residue(datas) icon_path = BRANDING_DIR / "logo.ico" icon = str(icon_path) if icon_path.is_file() else None a = Analysis( [str(ENTRY)], pathex=[ str(REPO_ROOT / "apps" / "studio" / "src"), str(REPO_ROOT / "shared" / "src"), ], binaries=binaries, datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[ "open3d", # NOTE: `orisys.finger` imports `.viewer` at module level, and `finger/viewer.py` # imports open3d *inside* its functions — so bundling it costs a few KB of Python and # pulls in no Open3D. Excluding it made `import orisys.finger` raise # ModuleNotFoundError in the frozen app, which took the whole window down with it # (the app cannot even reach logging). The `open3d` entry above is the one that # matters. "tkinter", "matplotlib", "IPython", "jupyter", "notebook", "pytest", # The app uses PySide6 only. Qt modules it never imports are excluded to keep the # bundle small — but `"PySide6"` itself must NOT appear here: excluding the whole # binding produces an EXE with no Qt at all (it fails at start-up, not at build # time). `PySide6.QtOpenGLWidgets` is required by the 3D pane and is deliberately # absent from this list. "PySide6.Qt3DAnimation", "PySide6.Qt3DCore", "PySide6.Qt3DExtras", "PySide6.Qt3DInput", "PySide6.Qt3DLogic", "PySide6.Qt3DRender", "PySide6.QtBluetooth", "PySide6.QtCharts", "PySide6.QtDataVisualization", "PySide6.QtGraphs", "PySide6.QtHelp", "PySide6.QtLocation", "PySide6.QtMultimedia", "PySide6.QtMultimediaWidgets", "PySide6.QtNfc", "PySide6.QtPdf", "PySide6.QtPdfWidgets", "PySide6.QtPositioning", "PySide6.QtQml", "PySide6.QtQuick", "PySide6.QtQuick3D", "PySide6.QtQuickControls2", "PySide6.QtQuickWidgets", "PySide6.QtRemoteObjects", "PySide6.QtScxml", "PySide6.QtSensors", "PySide6.QtSerialBus", "PySide6.QtSerialPort", "PySide6.QtSpatialAudio", "PySide6.QtStateMachine", "PySide6.QtTextToSpeech", "PySide6.QtWebChannel", "PySide6.QtWebEngineCore", "PySide6.QtWebEngineQuick", "PySide6.QtWebEngineWidgets", "PySide6.QtWebSockets", "PyQt5", "PyQt6", "PySide2", ], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False, ) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name="OrisysStudio", debug=False, bootloader_ignore_signals=False, strip=False, upx=False, console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, icon=icon, ) coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=False, upx_exclude=[], name="OrisysStudio", )