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.

Code Farmer Manor Chronicle
Code Farmer Manor Chronicle
Code Farmer Manor Chronicle
From Python Script to EXE: Packaging a Desktop Tool with PySide6 and PyInstaller

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.3

Takeaway: 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.json

contained 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.py

Parameter 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 kernel

Target 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 run

All 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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

PythonPackagingDesktop ApplicationWeChat LoginPyInstallerGUI DevelopmentPySide6QtWebEngine
Code Farmer Manor Chronicle
Written by

Code Farmer Manor Chronicle

A heart like drifting clouds, ever at ease; a mind like flowing water, free to roam.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.