Files
ykcheungab c4663ef769 构建:从打包数据中剔除 .gitignore 等版本控制残留并加校验
collect_data_files("orisys") 会把 SDK 包内受 git 跟踪的 src/orisys/.gitignore 一并拷进 _internal\orisys;
现在 spec 统一过滤版本控制与缓存文件名,build.ps1 第 6 步对我们自己发布的三个包硬失败、其它位置仅告警。
验证:清理重建 exit 0,dist 内 .git* / __pycache__ / 缓存文件 0 命中,Open3D 检查通过,冒烟测试
打印 "the application started" 与 "the packaged 3D scene loaded",输出 488.7 MB。
2026-10-10 16:17:44 +08:00

261 lines
8.5 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
#: 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",
)