Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions .github/workflows/build_windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ results/
*.egg-info/
.coverage
htmlcov/
build/
dist/
91 changes: 69 additions & 22 deletions build_windows.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()
132 changes: 127 additions & 5 deletions src/venting/__main__.py
Original file line number Diff line number Diff line change
@@ -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()
21 changes: 17 additions & 4 deletions venting.spec
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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=[],
Expand All @@ -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,
Expand Down
Loading