From Python Script to EXE: Packaging a Desktop Tool with PySide6 and PyInstaller
The author details building a Python desktop tool for automated bid data collection, covering technology selection (PySide6 over Playwright), three development pitfalls (config handling, redirect loops, invisible GUI crashes), PyInstaller packaging decisions (onedir mode, path resolution), and three key lessons for distributable GUI applications.
Requirements Drive Technology Choices
The project needed four capabilities: WeChat QR-code login yielding a 7-day token, scheduled collection at 11:00 and 16:00 daily, writing data to a local MySQL database, and a double-clickable EXE for non-technical colleagues. These requirements led directly to the following stack:
HTTP client: httpx 0.27.2 (sync support sufficient)
Desktop framework with embedded browser: PySide 6.8.3 using QWebEngineView (Chromium embedded)
MySQL driver: pymysql 1.1.1 (pure Python, no compile dependencies)
Packaging: PyInstaller (official hooks for PySide6)
The only real debate was the login window implementation.
Major Pivot: Playwright vs QtWebEngine
First version used Playwright. It launched a real Chromium window via chromium.launch(headless=False) and a background thread polled localStorage for the token. This worked but exposed three problems:
Browser kernel tightly bound to library version; upgrading the library invalidated the previously downloaded kernel, causing "Executable doesn't exist" errors on user machines.
Kernel installed in AppData\Local\ms-playwright, outside the project and site-packages, so PyInstaller could not bundle it; the EXE failed on other machines.
Separate browser window felt disconnected from the main app.
Second version switched to QtWebEngine. PySide6's QWebEngineView embeds Chromium directly inside the Qt window:
Kernel installs with PySide6, no version-binding issues.
PyInstaller bundles the kernel automatically; the EXE runs out-of-the-box on another machine.
Login dialog is the app's own window, seamless UX.
A single QTimer running runJavaScript() every second retrieves the token, eliminating the background thread.
Final dependencies converged to just three packages:
httpx==0.27.2
pymysql==1.1.1
PySide6==6.8.3Takeaway: For desktop tools that need embedded web pages and login-state capture, prefer QtWebEngine over automation-testing tools.
Three Development Pitfalls
Pitfall 1: Config File with Passwords Ignored by gitignore
config.jsoncontained database credentials and was excluded from version control. On a fresh clone the program crashed with FileNotFoundError at startup. Solution: document the required fields so users create the file manually, add a friendly error message at load time, and during deployment manually copy the password file next to the EXE — a step easily forgotten.
Pitfall 2: Redirects and beforeunload Trap on about:blank
The login page redirects immediately. Playwright's goto() defaults to waiting for the load event; the redirect aborts the load, throwing net::ERR_ABORTED, crashing the dialog thread, causing an infinite flicker of open/close. A second hidden issue: injected beforeunload listener on about:blank triggered a confirmation dialog that the dialog's own dismiss handler intercepted, aborting navigation again. Fix:
# 1. Relax wait strategy to domcontentloaded, returns before redirect
page.goto(url, wait_until="domcontentloaded", timeout=30000)
# 2. Do not inject beforeunload on about:blank; inject only after navigation to real page
if page.url != "about:blank":
page.evaluate(inject_beforeunload_js)Lesson: when injecting global interception logic, always account for the "navigation hasn't happened yet" scenario to avoid self-blocking.
Pitfall 3: GUI Crashes Invisibly
With --windowed packaging, the GUI has no console; tracebacks go to stderr and disappear. Debugging became guesswork. Solution: install a global exception hook that writes uncaught exceptions into the GUI's log widget:
def _excepthook(tp, val, tb):
log("Uncaught exception:
" + "".join(
traceback.format_exception(tp, val, tb)).rstrip())
sys.excepthook = _excepthook # set in main()After this, users could screenshot the log box and send it, drastically reducing diagnosis time. GUI programs must ship their own "black box."
Packaging to EXE: One Command
With a clean environment and explicit dependencies, packaging was surprisingly smooth. Core command:
python -m PyInstaller --noconfirm --clean --windowed --name HemaCollector gui.pyParameter breakdown: --windowed: suppresses the console black window; without it, double-click shows an unprofessional terminal. --name: sets the EXE name; default would be gui. --noconfirm --clean: overwrites previous build artifacts and clears cache; otherwise repeated builds may leave stale files.
After 2-3 minutes the output appears in dist\HemaCollector\:
dist\HemaCollector\
├── HemaCollector.exe ← double-click to start
├── config.json ← manually copied (contains password)
└── _internal\ ← runtime libraries, including QtWebEngine kernelTarget machines need neither Python nor a browser kernel; the whole folder can be zipped and shipped.
Two Critical Decisions That Made Packaging Work
Decision 1: Choose onedir, Not onefile
Single-file EXE looks convenient but extracts to a temp directory on every launch. For a QtWebEngine app carrying a full Chromium kernel, startup becomes noticeably slower, and the WebEngine subprocess QtWebEngineProcess.exe often fails to locate resources under onefile. GUI apps with embedded kernels: always use onedir.
Decision 2: Use "EXE Directory" for Paths, Not "Script Directory"
During source runs Path(__file__).parent points to the project root; after packaging __file__ points to a temporary extraction directory, breaking config reads/writes. Solution: a shared module paths.py:
# paths.py
import sys
from pathlib import Path
if getattr(sys, "frozen", False): # PyInstaller flag
ROOT = Path(sys.executable).resolve().parent # EXE directory
else:
ROOT = Path(__file__).resolve().parent # source runAll config and state ( state/ for login tokens) access uses paths.ROOT. The same code behaves identically in source and EXE; updating the EXE does not lose login state.
Packaging is not a final step; it's a design constraint from day one.
Retrospective: Three Lessons
Choose technology for distribution. Playwright is excellent for testing, but for shipping to end-users, native integration (QtWebEngine) eliminates a whole class of environment problems.
GUI apps must carry a "black box." Global exception hook plus in-UI log panel is the lifeline for remote debugging; a screenshot from the user pinpoints the issue instantly.
Packaging is a design constraint, not an afterthought. Using paths.ROOT from the start and keeping dependencies minimal turns the final packaging into a single command.
These aren't tips; they're scars. Each became clear only after stepping on the corresponding landmine.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Code Farmer Manor Chronicle
A heart like drifting clouds, ever at ease; a mind like flowing water, free to roam.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
