From 7c15af66b4e424666690c6312cddc486de10c820 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 00:09:08 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20prevent=20silent=20exe=20exit=20?= =?UTF-8?q?=E2=80=94=20add=20crash=20logging=20and=20default=20to=20GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the exe was built with console=False (GUI subsystem) but the entry point required an argparse subcommand. Double-clicking the exe provided no args, causing argparse to call sys.exit(2) with the error message going to invisible stderr. The process appeared in Task Manager briefly and then vanished with no visible output. Changes: - __main__.py: add bootstrap crash logging to %LOCALAPPDATA%\venting\logs\ and a Windows MessageBox on fatal errors; when running as a frozen exe with no args, launch the GUI directly instead of requiring a subcommand - venting.spec: add PySide6 plugin/translation data collection and dynamic libs to prevent Qt platform plugin failures; widen UPX exclusions to cover all Qt/PySide6 binaries (*.pyd, Qt6*.dll, pyside6*.dll) - venting_cli.spec: new spec for a console=True CLI executable - build_windows.py: build both venting.exe (GUI) and venting_cli.exe (CLI); support --gui-only, --cli-only, and --debug flags - build_windows.yml: add smoke tests (--help and gate --single) for the CLI exe; publish both artifacts in the release - .gitignore: add build/ and dist/ https://claude.ai/code/session_015sYv5NFMoDqEA1J6yV757i --- .github/workflows/build_windows.yml | 25 +++++- .gitignore | 2 + build_windows.py | 91 ++++++++++++++----- src/venting/__main__.py | 132 ++++++++++++++++++++++++++-- venting.spec | 21 ++++- venting_cli.spec | 61 +++++++++++++ 6 files changed, 298 insertions(+), 34 deletions(-) create mode 100644 venting_cli.spec 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, +)