-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathGModular.spec
More file actions
278 lines (250 loc) · 11.4 KB
/
Copy pathGModular.spec
File metadata and controls
278 lines (250 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# -*- mode: python ; coding: utf-8 -*-
"""
GModular — KotOR Module Editor
PyInstaller spec file (Windows + Linux) v2.0.10
Produces: dist/GModular.exe (Windows) or dist/GModular (Linux)
Size: ~60-100 MB (single-file, no console window)
Usage:
python -m PyInstaller GModular.spec --clean --noconfirm
NOTE on moderngl:
moderngl requires Microsoft Visual C++ Build Tools to compile from source.
build.bat installs it via --only-binary so no compiler is needed.
If moderngl is absent the viewport falls back to PyOpenGL (pure Python).
NOTE on Python version:
qtpy + PyQt5 backend: Python 3.8 - 3.12 (PyQt5 wheels available)
qtpy + PyQt6 backend: Python 3.8+ (PyQt6 wheels available)
Set QT_API env var to 'pyqt5' or 'pyqt6' to choose backend explicitly.
Default: qtpy auto-detects the installed backend.
HOW Qt COLLECTION WORKS (v2.3 — qtpy-aware):
qtpy is the compatibility shim; the actual Qt binaries come from the
underlying backend (PyQt5 or PyQt6). Three-layer defence:
Layer 1 - hookspath=['hooks']:
hooks/hook-PyQt5.py and hooks/hook-PyQt5.QtWidgets.py call
collect_all('PyQt5'), which copies Qt5*.dll, platforms/, styles/,
imageformats/, and all .pyd extension modules.
hook-qtpy.py (if present) similarly collects the qtpy shim.
Layer 2 - spec-level collect_all() call (wrapped in try/except):
Explicit call here adds the results to binaries= and datas= so
they end up in the EXE even if the hook path mechanism is skipped.
Layer 3 - runtime_hooks=['runtime_hooks/pyi_rth_pyqt5.py']:
Runs inside the frozen EXE at boot time and pre-imports every
Qt module via qtpy, giving a clear error at startup rather than a
cryptic NameError inside GUI code.
Without all three layers: QGroupBox (and every other Qt widget class)
raises NameError because the .pyd binding file was never included.
"""
import sys
import importlib.util
from pathlib import Path
# ── Paths ─────────────────────────────────────────────────────────────────────
HERE = Path(SPECPATH) # noqa: F821 (PyInstaller built-in)
# ── Detect available optional backends ───────────────────────────────────────
_has_moderngl = importlib.util.find_spec("moderngl") is not None
_has_pyopengl = importlib.util.find_spec("OpenGL") is not None
_has_flask = importlib.util.find_spec("flask") is not None
_has_watchdog = importlib.util.find_spec("watchdog") is not None
print(f"[spec] moderngl : {'YES' if _has_moderngl else 'NO (will use PyOpenGL fallback)'}")
print(f"[spec] PyOpenGL : {'YES' if _has_pyopengl else 'NO'}")
print(f"[spec] flask : {'YES' if _has_flask else 'NO (optional - skipped)'}")
print(f"[spec] watchdog : {'YES' if _has_watchdog else 'NO (optional - skipped)'}")
# ── Detect Qt backend for collection ─────────────────────────────────────────
import os as _os
_qt_api = _os.environ.get("QT_API", "").lower()
_qt_backend = "PyQt6" if _qt_api == "pyqt6" else "PyQt5" # default to PyQt5
print(f"[spec] Qt backend : {_qt_backend} (QT_API={_qt_api or 'auto'})")
# ── Layer 2: collect_all for Qt backend + qtpy shim (try/except so spec never hard-fails) ─
# Even if this fails, Layer 1 (hookspath) and Layer 3 (runtime hook) still run.
try:
from PyInstaller.utils.hooks import collect_all as _collect_all # noqa
_pyqt5_datas, _pyqt5_binaries, _pyqt5_hiddenimports = _collect_all(_qt_backend)
print(f"[spec] {_qt_backend} collect_all: {len(_pyqt5_binaries)} binaries, "
f"{len(_pyqt5_datas)} datas, {len(_pyqt5_hiddenimports)} hidden")
except Exception as _e:
print(f"[spec] WARNING: collect_all('{_qt_backend}') failed ({_e}); "
f"relying on hookspath for Qt collection")
_pyqt5_datas, _pyqt5_binaries, _pyqt5_hiddenimports = [], [], []
# Also collect qtpy shim
try:
from PyInstaller.utils.hooks import collect_all as _collect_all # noqa
_qtpy_datas, _qtpy_binaries, _qtpy_hiddenimports = _collect_all("qtpy")
_pyqt5_datas += _qtpy_datas
_pyqt5_binaries += _qtpy_binaries
_pyqt5_hiddenimports += _qtpy_hiddenimports
except Exception:
pass
# ── Data files bundled into the EXE ─────────────────────────────────────────
datas = list(_pyqt5_datas)
if (HERE / "assets").exists():
datas.append((str(HERE / "assets"), "assets"))
if (HERE / "resources").exists():
datas.append((str(HERE / "resources"), "resources"))
# Qt Designer .ui files — must be bundled alongside the Python code
_ui_dir = HERE / "gmodular" / "gui" / "ui"
if _ui_dir.exists():
datas.append((str(_ui_dir), "gmodular/gui/ui"))
print(f"[spec] Qt Designer .ui files: {len(list(_ui_dir.glob('*.ui')))} files bundled")
else:
print("[spec] WARNING: gmodular/gui/ui/ not found — .ui files will not be bundled")
# ── Hidden imports ────────────────────────────────────────────────────────────
# NOTE: The regex in tests/test_module_state.py::TestSpecHiddenImports
# scans this literal list for required entries — keep all GModular imports here.
hidden_imports = [
# ── GModular package ──────────────────────────────────────────────────
"gmodular",
"gmodular.core",
"gmodular.core.module_state",
"gmodular.formats",
"gmodular.formats.gff_types",
"gmodular.formats.gff_reader",
"gmodular.formats.gff_writer",
"gmodular.formats.archives",
"gmodular.formats.mdl_parser",
"gmodular.formats.mdl_writer", # binary MDL/MDX writer (v2.0.6)
"gmodular.engine",
"gmodular.engine.player_controller",
"gmodular.engine.npc_instance",
"gmodular.gui",
"gmodular.gui.main_window",
"gmodular.gui.viewport",
"gmodular.gui.viewport_camera", # OrbitCamera (extracted v2.0.7)
"gmodular.gui.viewport_shaders", # GLSL shaders (extracted v2.0.6)
"gmodular.gui.viewport_renderer", # _EGLRenderer (extracted v2.0.7)
"gmodular.gui.inspector",
"gmodular.gui.asset_palette",
"gmodular.gui.scene_outline",
"gmodular.gui.walkmesh_editor",
"gmodular.gui.script_library",
"gmodular.gui.mod_packager_dialog",
"gmodular.gui.patrol_editor",
"gmodular.gui.room_assembly",
"gmodular.gui.ui_loader",
"gmodular.gui.ui",
"gmodular.formats.mod_packager",
"gmodular.formats.twoda_loader",
"gmodular.ipc",
"gmodular.ipc.bridges",
"gmodular.ipc.callback_server",
"gmodular.utils",
"gmodular.utils.resource_manager",
# ── qtpy shim (backend-agnostic Qt wrapper) ───────────────────────────
"qtpy",
"qtpy.QtWidgets",
"qtpy.QtCore",
"qtpy.QtGui",
"qtpy.QtOpenGL",
"qtpy.compat",
"qtpy.uic",
# ── PyQt5 explicit (belt-and-suspenders, default backend) ─────────────
"PyQt5",
"PyQt5.QtWidgets",
"PyQt5.QtCore",
"PyQt5.QtGui",
"PyQt5.QtOpenGL",
"PyQt5.QtPrintSupport",
"PyQt5.sip",
# ── numpy ─────────────────────────────────────────────────────────────
"numpy",
"numpy.core",
"numpy.core._multiarray_umath",
# ── requests (IPC) ────────────────────────────────────────────────────
"requests",
"urllib3",
"certifi",
"charset_normalizer",
"idna",
]
# Merge collect_all hidden imports (deduped)
hidden_imports += [h for h in _pyqt5_hiddenimports if h not in hidden_imports]
# Optional: moderngl (preferred GL backend)
if _has_moderngl:
hidden_imports.append("moderngl")
# Optional: PyOpenGL (pure-Python fallback)
if _has_pyopengl:
hidden_imports += [
"OpenGL",
"OpenGL.GL",
"OpenGL.GLU",
"OpenGL.arrays",
"OpenGL.arrays.numpymodule",
"OpenGL.platform",
"OpenGL.platform.win32",
]
# Optional: watchdog (file watcher)
if _has_watchdog:
hidden_imports += [
"watchdog",
"watchdog.observers",
"watchdog.events",
]
# Optional: flask / werkzeug (soft IPC dependency)
if _has_flask:
hidden_imports += ["flask", "werkzeug", "jinja2"]
# ── Excludes (keep EXE small) ─────────────────────────────────────────────────
excludes = [
"tkinter", "_tkinter",
"matplotlib", "scipy", "pandas",
"notebook", "IPython",
"pytest", "_pytest",
"PyQt5.QtWebEngine", "PyQt5.QtWebEngineWidgets", "PyQt5.QtWebEngineCore",
"PyQt5.QtMultimedia", "PyQt5.QtSql",
"PyQt5.QtBluetooth", "PyQt5.QtNfc",
"PyQt5.QtLocation", "PyQt5.QtPositioning", "PyQt5.QtSensors",
"PyQt5.QtXml", "PyQt5.QtXmlPatterns",
"PyQt5.QtHelp", "PyQt5.QtDesigner",
"unittest", "doctest", "pdb",
"email", "html", "http.server", "xmlrpc",
# NOTE: multiprocessing intentionally NOT excluded — PyInstaller Windows
# bootloader requires it for the freeze_support() call on Windows.
"sip", # bare 'sip' is obsolete; PyQt5.sip is used instead
]
if not _has_moderngl:
excludes.append("moderngl")
if not _has_pyopengl:
excludes += ["OpenGL", "OpenGL.GL"]
if not _has_flask:
excludes += ["flask", "werkzeug", "jinja2"]
if not _has_watchdog:
excludes += ["watchdog"]
# ── Icon path ─────────────────────────────────────────────────────────────────
_icon = str(HERE / "assets" / "icons" / "gmodular.ico")
_icon_arg = _icon if (HERE / "assets" / "icons" / "gmodular.ico").exists() else None
# ── Analysis ─────────────────────────────────────────────────────────────────
a = Analysis(
[str(HERE / "main.py")],
pathex=[str(HERE)],
binaries=list(_pyqt5_binaries),
datas=datas,
hiddenimports=hidden_imports,
# Layer 1: local hooks trigger collect_all for PyQt5
hookspath=["hooks"],
hooksconfig={},
# Layer 3: runtime hook pre-imports all PyQt5 modules inside the EXE
runtime_hooks=["runtime_hooks/pyi_rth_pyqt5.py"],
excludes=excludes,
win_no_prefer_redirects=False,
win_private_assemblies=False,
noarchive=False,
optimize=1,
)
pyz = PYZ(a.pure, a.zipped_data) # noqa: F821
exe = EXE( # noqa: F821
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="GModular",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # disabled - UPX often absent on fresh Windows
runtime_tmpdir=None,
console=False, # no console window
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=_icon_arg,
)