diff --git a/.github/workflows/build_windows.yml b/.github/workflows/build_windows.yml index 7efd475..d9d2464 100644 --- a/.github/workflows/build_windows.yml +++ b/.github/workflows/build_windows.yml @@ -33,6 +33,16 @@ jobs: - name: Build EXE run: python build_windows.py + - name: Smoke test — CLI help + run: | + dist\venting_cli.exe --help + shell: cmd + + - name: Smoke test — CLI gate + run: | + dist\venting_cli.exe gate --single + shell: cmd + - name: Get short SHA id: sha run: echo "short=$("${{ github.sha }}".Substring(0,7))" >> $env:GITHUB_OUTPUT @@ -55,9 +65,18 @@ jobs: Automatic build from `main` branch. Commit: ${{ github.sha }} - **Download:** `venting.exe` from Assets below. - Runs without installing Python. - files: dist/venting.exe + **Downloads:** + - `venting.exe` — GUI application (double-click to launch) + - `venting_cli.exe` — CLI version (run from terminal with subcommands) + + Both run without installing Python. + + **Crash logs:** If `venting.exe` fails to start, check: + - `%LOCALAPPDATA%\venting\logs\boot.log` + - `%LOCALAPPDATA%\venting\logs\crash.log` + files: | + dist/venting.exe + dist/venting_cli.exe prerelease: false make_latest: true env: diff --git a/.gitignore b/.gitignore index 55d4dfb..8897b73 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ results/ *.egg-info/ .coverage htmlcov/ +build/ +dist/ diff --git a/build_windows.py b/build_windows.py index 05c6cef..b2661c6 100644 --- a/build_windows.py +++ b/build_windows.py @@ -1,6 +1,16 @@ #!/usr/bin/env python3 -"""Build venting.exe. Run: python build_windows.py""" +"""Build venting.exe (GUI) and venting_cli.exe (CLI). +Usage: + python build_windows.py # build both + python build_windows.py --gui-only # GUI exe only + python build_windows.py --cli-only # CLI exe only + python build_windows.py --debug # diagnostic build (console=True, no UPX) +""" + +from __future__ import annotations + +import argparse import shutil import subprocess import sys @@ -14,41 +24,78 @@ def check(pkg): subprocess.check_call([sys.executable, "-m", "pip", "install", pkg]) -def main(): - check("PyInstaller") - upx = bool(shutil.which("upx")) - if not upx: - print( - "UPX not found -- exe will be larger. https://github.com/upx/upx/releases" - ) - - for d in ["build", "dist"]: - if Path(d).exists(): - shutil.rmtree(d) - +def build_spec(spec, *, upx, extra_flags=None): cmd = [ sys.executable, "-m", "PyInstaller", - "venting.spec", + spec, "--clean", "--noconfirm", ] if not upx: cmd.append("--noupx") + if extra_flags: + cmd.extend(extra_flags) result = subprocess.run(cmd) - if result.returncode != 0: - sys.exit(1) + return result.returncode == 0 + + +def main(): + parser = argparse.ArgumentParser(description="Build venting Windows executables") + parser.add_argument("--gui-only", action="store_true", help="Build GUI exe only") + parser.add_argument("--cli-only", action="store_true", help="Build CLI exe only") + parser.add_argument( + "--debug", + action="store_true", + help="Diagnostic build: no UPX, debug logging", + ) + args = parser.parse_args() + + check("PyInstaller") + upx = bool(shutil.which("upx")) and not args.debug + if not upx and not args.debug: + print( + "UPX not found -- exe will be larger. " + "https://github.com/upx/upx/releases" + ) - exe = Path("dist/venting.exe") - if exe.exists(): - print(f"\nDONE: dist/venting.exe ({exe.stat().st_size / 1e6:.0f} MB)") - print("The file runs without installing Python -- ready to distribute.") - else: - print("ERROR: dist/venting.exe was not created") + for d in ["build", "dist"]: + if Path(d).exists(): + shutil.rmtree(d) + + build_gui = not args.cli_only + build_cli = not args.gui_only + + extra = [] + if args.debug: + extra.append("--log-level=DEBUG") + + ok = True + + if build_gui: + print("\n=== Building venting.exe (GUI, console=False) ===") + if not build_spec("venting.spec", upx=upx, extra_flags=extra): + print("ERROR: venting.exe build failed") + ok = False + + if build_cli: + print("\n=== Building venting_cli.exe (CLI, console=True) ===") + if not build_spec("venting_cli.spec", upx=upx, extra_flags=extra): + print("ERROR: venting_cli.exe build failed") + ok = False + + if not ok: sys.exit(1) + print("\n=== Build results ===") + for name in ["venting.exe", "venting_cli.exe"]: + exe = Path("dist") / name + if exe.exists(): + print(f" {name}: {exe.stat().st_size / 1e6:.0f} MB") + print("\nReady to distribute. No Python installation required.") + if __name__ == "__main__": main() diff --git a/src/venting/__main__.py b/src/venting/__main__.py index dfe23d6..748e58d 100644 --- a/src/venting/__main__.py +++ b/src/venting/__main__.py @@ -1,7 +1,129 @@ -try: - from .cli import main -except ImportError: - from venting.cli import main +"""Bootstrap entry point for venting. + +Provides: +- Earliest-possible crash logging to a file so that failures in windowed + (console=False) PyInstaller builds are never silent. +- Frozen-exe default: if running as a PyInstaller bundle with no CLI args, + launch the GUI directly instead of requiring a subcommand. +""" + +from __future__ import annotations + +import logging +import os +import sys +import traceback +from pathlib import Path + +# --------------------------------------------------------------------------- +# 1. Bootstrap log directory +# --------------------------------------------------------------------------- +_FROZEN: bool = getattr(sys, "frozen", False) + + +def _log_dir() -> Path: + """Return a writable directory for boot/crash logs.""" + if sys.platform == "win32": + base = os.environ.get("LOCALAPPDATA") or os.environ.get("TEMP") or "." + else: + base = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state") + d = Path(base) / "venting" / "logs" + try: + d.mkdir(parents=True, exist_ok=True) + except OSError: + d = Path(os.environ.get("TEMP", "/tmp")) / "venting_logs" + d.mkdir(parents=True, exist_ok=True) + return d + + +def _setup_logging() -> logging.Logger: + log = logging.getLogger("venting.boot") + log.setLevel(logging.DEBUG) + try: + fh = logging.FileHandler(_log_dir() / "boot.log", encoding="utf-8", delay=False) + fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + log.addHandler(fh) + except OSError: + pass # Cannot log — will still try to show MessageBox on fatal + return log + + +def _show_fatal_messagebox(crash_path: str, short_msg: str) -> None: + """Show a minimal Windows MessageBox on fatal error (no new deps).""" + if sys.platform != "win32": + return + try: + import ctypes + + ctypes.windll.user32.MessageBoxW( + 0, + f"Venting crashed on startup.\n\n{short_msg}\n\n" + f"Full traceback saved to:\n{crash_path}", + "Venting — Fatal Error", + 0x10, # MB_ICONERROR + ) + except Exception: + pass # Best-effort + + +# --------------------------------------------------------------------------- +# 2. Main entry logic +# --------------------------------------------------------------------------- +def _entry() -> None: + log = _setup_logging() + log.info( + "boot: argv=%s executable=%s frozen=%s cwd=%s", + sys.argv, + sys.executable, + _FROZEN, + os.getcwd(), + ) + + try: + from venting import __version__ + + log.info("venting version: %s python: %s", __version__, sys.version) + except Exception: + log.warning("could not determine venting version") + + # ------------------------------------------------------------------ + # Frozen exe with no args → launch GUI directly (H1 fix) + # ------------------------------------------------------------------ + if _FROZEN and len(sys.argv) <= 1: + log.info("frozen build, no args → launching GUI") + try: + from venting.gui.main import main as gui_main + + raise SystemExit(gui_main()) + except SystemExit: + raise + except Exception: + tb = traceback.format_exc() + crash_file = _log_dir() / "crash.log" + crash_file.write_text(tb, encoding="utf-8") + log.critical("GUI launch failed:\n%s", tb) + _show_fatal_messagebox(str(crash_file), tb.splitlines()[-1]) + raise SystemExit(1) from None + + # ------------------------------------------------------------------ + # Normal CLI path + # ------------------------------------------------------------------ + try: + from venting.cli import main + + main() + except SystemExit as exc: + log.info("CLI exited with code %s", exc.code) + raise + except Exception: + tb = traceback.format_exc() + crash_file = _log_dir() / "crash.log" + crash_file.write_text(tb, encoding="utf-8") + log.critical("unhandled exception:\n%s", tb) + if _FROZEN: + _show_fatal_messagebox(str(crash_file), tb.splitlines()[-1]) + raise SystemExit(1) from None + if __name__ == "__main__": - main() + _entry() diff --git a/venting.spec b/venting.spec index c642ac8..e0b8a3d 100644 --- a/venting.spec +++ b/venting.spec @@ -1,4 +1,4 @@ -# venting.spec +# venting.spec — GUI executable (console=False, launches GUI on double-click) from PyInstaller.utils.hooks import collect_data_files, collect_submodules block_cipher = None @@ -20,12 +20,22 @@ hidden_imports = [ datas = [ *collect_data_files('pyqtgraph'), *collect_data_files('scipy'), + # PySide6 Qt plugins (platforms/qwindows.dll, styles, imageformats, etc.) + *collect_data_files('PySide6', subdir='plugins'), + *collect_data_files('PySide6', subdir='translations'), ] +# PySide6 shared libraries that PyInstaller hooks may miss +try: + from PyInstaller.utils.hooks import collect_dynamic_libs + binaries = collect_dynamic_libs('PySide6') +except ImportError: + binaries = [] + a = Analysis( ['src/venting/__main__.py'], pathex=['src'], - binaries=[], + binaries=binaries, datas=datas, hiddenimports=hidden_imports, hookspath=[], @@ -43,8 +53,11 @@ exe = EXE( strip=False, upx=True, upx_exclude=[ - 'Qt6Core.dll', 'Qt6Gui.dll', 'Qt6Widgets.dll', - 'Qt6OpenGL.dll', 'Qt6OpenGLWidgets.dll', 'PySide6/*.pyd', + # Never UPX-compress Qt/PySide6 binaries — causes loader failures + 'Qt6*.dll', + 'pyside6*.dll', + 'shiboken6*.dll', + '*.pyd', ], console=False, runtime_tmpdir=None, diff --git a/venting_cli.spec b/venting_cli.spec new file mode 100644 index 0000000..d2652e1 --- /dev/null +++ b/venting_cli.spec @@ -0,0 +1,61 @@ +# venting_cli.spec — CLI executable (console=True, for terminal usage) +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +block_cipher = None + +hidden_imports = [ + 'scipy', 'scipy.integrate', 'scipy.integrate._ivp', + 'scipy.integrate._ivp.radau', 'scipy.integrate._ivp.common', + 'scipy.integrate._ivp.base', 'scipy.linalg', + 'scipy.linalg.cython_blas', 'scipy.linalg.cython_lapack', + 'scipy.sparse', 'scipy.sparse.linalg', 'scipy.special', + 'scipy.special._ufuncs', 'scipy._lib.messagestream', + 'numpy', 'numpy.core._multiarray_umath', + 'PySide6', 'PySide6.QtCore', 'PySide6.QtGui', + 'PySide6.QtWidgets', 'PySide6.QtOpenGL', 'PySide6.QtOpenGLWidgets', + 'pyqtgraph', 'pyqtgraph.graphicsItems', 'pyqtgraph.widgets', + *collect_submodules('venting'), +] + +datas = [ + *collect_data_files('pyqtgraph'), + *collect_data_files('scipy'), + *collect_data_files('PySide6', subdir='plugins'), + *collect_data_files('PySide6', subdir='translations'), +] + +try: + from PyInstaller.utils.hooks import collect_dynamic_libs + binaries = collect_dynamic_libs('PySide6') +except ImportError: + binaries = [] + +a = Analysis( + ['src/venting/__main__.py'], + pathex=['src'], + binaries=binaries, + datas=datas, + hiddenimports=hidden_imports, + hookspath=[], + runtime_hooks=[], + excludes=['tkinter', 'matplotlib', 'IPython', 'pytest', 'setuptools'], + cipher=block_cipher, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], + name='venting_cli', + debug=False, + strip=False, + upx=True, + upx_exclude=[ + 'Qt6*.dll', + 'pyside6*.dll', + 'shiboken6*.dll', + '*.pyd', + ], + console=True, + runtime_tmpdir=None, +)