Files
orisys-app/build/studio/orisys-studio.spec
T
2026-09-21 09:34:56 +08:00

216 lines
6.7 KiB
RPMSpec

# -*- 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 ``<prefix>\Library\bin`` or
#: ``<prefix>\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
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:
pass
try:
cv2_datas, cv2_binaries, cv2_hidden = collect_all("cv2")
datas += cv2_datas
binaries += cv2_binaries
hiddenimports += list(cv2_hidden)
except Exception:
pass
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",
)