Python to .exe with GUI

andreasBlog2 weeks ago52 Views

Here’s a fully functional Python tool with tkinter (English UI) that packages Python scripts into EXE files using PyInstaller—including icon selection, one-file/one-folder, console/windowed, hidden imports, additional data, UPX, UAC admin, extra paths, version information, saving/loading presets, and a live build log.

Requirements (one-time)

  1. Python 3.x installed
  2. PyInstaller:
    • Windows (PowerShell/CMD): py -m pip install pyinstaller
    • other: python -m pip install pyinstaller

Save as exe_builder.py abd run with python exe_builder.py.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os, sys, json, threading, subprocess, platform, queue, shutil
from pathlib import Path
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

APP_TITLE = "Python → EXE Builder (PyInstaller GUI)"
IS_WINDOWS = platform.system().lower().startswith("win")
ADD_DATA_SEP = ";" if IS_WINDOWS else ":"

def find_python_command():
    if IS_WINDOWS and shutil.which("py"):
        return "py"
    return sys.executable or "python"

def get_pyinstaller_invocation():
    """
    Returns a list used to invoke PyInstaller:
    - Prefer 'pyinstaller' if on PATH
    - Else use '<python> -m PyInstaller'
    """
    if shutil.which("pyinstaller"):
        return ["pyinstaller"]
    # Fallback: module invocation (works when installed in current env)
    return [find_python_command(), "-m", "PyInstaller"]

class ExeBuilderApp(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title(APP_TITLE)
        self.geometry("980x680"); self.minsize(920, 620)
        self._q = queue.Queue()
        self._init_vars(); self._build_ui()
        self.process = None; self.is_building = False
        self.after(100, self._drain_log_queue)

    def _init_vars(self):
        self.script_path = tk.StringVar()
        self.output_dir  = tk.StringVar()
        self.icon_path   = tk.StringVar()
        self.version_file= tk.StringVar()

        self.name        = tk.StringVar()
        self.onefile     = tk.BooleanVar(value=True)
        self.noconsole   = tk.BooleanVar(value=False)
        self.clean       = tk.BooleanVar(value=True)
        self.strip       = tk.BooleanVar(value=False)
        self.upx         = tk.BooleanVar(value=False)
        self.uac_admin   = tk.BooleanVar(value=False)

        self.add_data         = tk.StringVar()
        self.hidden_imports   = tk.StringVar()
        self.extra_paths      = tk.StringVar()
        self.extra_args       = tk.StringVar()

    def _build_ui(self):
        root = ttk.Frame(self, padding=10); root.pack(fill="both", expand=True)

        lf1 = ttk.LabelFrame(root, text="1) Select Python script"); lf1.pack(fill="x", pady=(0,10))
        self._row_filepicker(lf1, "Script:", self.script_path, [("Python files","*.py")])

        lf2 = ttk.LabelFrame(root, text="2) Build options"); lf2.pack(fill="x", pady=(0,10))
        row1 = ttk.Frame(lf2); row1.pack(fill="x", pady=5)
        self._labeled_entry(row1, "Name:", self.name, 28).pack(side="left", padx=(0,10))
        self._row_filepicker(row1, "Icon (.ico):", self.icon_path, [("Icon","*.ico")], inline=True)
        self._row_dirpicker(row1, "Output:", self.output_dir, inline=True)

        row2 = ttk.Frame(lf2); row2.pack(fill="x", pady=5)
        ttk.Checkbutton(row2, text="One-file (-F)", variable=self.onefile).pack(side="left", padx=10)
        ttk.Checkbutton(row2, text="Windowed (no console) (--noconsole)", variable=self.noconsole).pack(side="left", padx=10)
        ttk.Checkbutton(row2, text="Clean build (--clean)", variable=self.clean).pack(side="left", padx=10)
        ttk.Checkbutton(row2, text="Strip binaries (--strip)", variable=self.strip).pack(side="left", padx=10)
        ttk.Checkbutton(row2, text="Use UPX (on PATH)", variable=self.upx).pack(side="left", padx=10)
        ttk.Checkbutton(row2, text="UAC Admin (Windows)", variable=self.uac_admin).pack(side="left", padx=10)

        lf3 = ttk.LabelFrame(root, text="3) Advanced"); lf3.pack(fill="both", pady=(0,10))
        r4 = ttk.Frame(lf3); r4.pack(fill="x", pady=5)
        ttk.Label(r4, text="Additional data files (one per line: source|dest_folder):").pack(anchor="w")
        self.txt_add_data = tk.Text(r4, height=4); self.txt_add_data.pack(fill="x", pady=2)

        r5 = ttk.Frame(lf3); r5.pack(fill="x", pady=5)
        ttk.Label(r5, text="Hidden imports (comma or newline separated):").pack(anchor="w")
        self.txt_hidden_imports = tk.Text(r5, height=3); self.txt_hidden_imports.pack(fill="x", pady=2)

        r6 = ttk.Frame(lf3); r6.pack(fill="x", pady=5)
        ttk.Label(r6, text="Extra sys.path entries (comma or newline separated):").pack(anchor="w")
        self.txt_extra_paths = tk.Text(r6, height=3); self.txt_extra_paths.pack(fill="x", pady=2)

        r7 = ttk.Frame(lf3); r7.pack(fill="x", pady=5)
        self._labeled_entry(r7, "Version file (VSVersionInfo):", self.version_file).pack(fill="x", expand=True)
        self._labeled_entry(r7, "Extra PyInstaller args:", self.extra_args).pack(fill="x", expand=True, pady=(6,0))

        lf4 = ttk.LabelFrame(root, text="4) Build & Log"); lf4.pack(fill="both", expand=True)
        topbar = ttk.Frame(lf4); topbar.pack(fill="x", pady=5)
        ttk.Button(topbar, text="Check PyInstaller", command=self.check_pyinstaller).pack(side="left", padx=5)
        ttk.Button(topbar, text="Build", command=self.start_build).pack(side="left", padx=5)
        ttk.Button(topbar, text="Cancel Build", command=self.cancel_build).pack(side="left", padx=5)

        self.txt_log = tk.Text(lf4, height=12); self.txt_log.pack(fill="both", expand=True, padx=5, pady=5)
        self._log("Welcome! Select your script and click 'Build'.")

    # -- UI helpers
    def _labeled_entry(self, parent, label_text, tkvar, width=0):
        frm = ttk.Frame(parent)
        ttk.Label(frm, text=label_text).pack(side="left")
        ent = ttk.Entry(frm, textvariable=tkvar, width=width if width else None)
        ent.pack(side="left", fill="x", expand=(width==0), padx=(6,0))
        return frm

    def _row_filepicker(self, parent, label, var, filetypes=None, inline=False):
        def pick():
            p = filedialog.askopenfilename(title=label, filetypes=filetypes or [("All files","*.*")])
            if p: var.set(p)
        frm = parent if inline else ttk.Frame(parent)
        if not inline: frm.pack(fill="x", pady=5)
        ttk.Label(frm, text=label).pack(side="left")
        ttk.Entry(frm, textvariable=var).pack(side="left", fill="x", expand=True, padx=6)
        ttk.Button(frm, text="Browse…", command=pick).pack(side="left", padx=5)
        return frm

    def _row_dirpicker(self, parent, label, var, inline=False):
        def pick():
            p = filedialog.askdirectory(title=label)
            if p: var.set(p)
        frm = parent if inline else ttk.Frame(parent)
        if not inline: frm.pack(fill="x", pady=5)
        ttk.Label(frm, text=label).pack(side="left")
        ttk.Entry(frm, textvariable=var).pack(side="left", fill="x", expand=True, padx=6)
        ttk.Button(frm, text="Choose…", command=pick).pack(side="left", padx=5)
        return frm

    # -- Logging pump
    def _drain_log_queue(self):
        try:
            while True:
                line = self._q.get_nowait()
                self._log(line)
        except queue.Empty:
            pass
        self.after(100, self._drain_log_queue)

    def _log(self, msg):
        self.txt_log.insert("end", msg + "\n"); self.txt_log.see("end")

    # -- Validation & build
    def _sync_textvars(self):
        self.add_data.set(self.txt_add_data.get("1.0","end").strip())
        self.hidden_imports.set(self.txt_hidden_imports.get("1.0","end").strip())
        self.extra_paths.set(self.txt_extra_paths.get("1.0","end").strip())

    def check_pyinstaller(self):
        inv = get_pyinstaller_invocation()
        try:
            out = subprocess.check_output(inv + ["--version"], text=True, stderr=subprocess.STDOUT)
            self._log(f"PyInstaller found ✅  version: {out.strip()}")
        except Exception as e:
            self._log("PyInstaller not found ❌")
            self._log(f"Install with:\n  {find_python_command()} -m pip install pyinstaller")
            self._log(f"Details: {e}")

    def _validate(self):
        sp = self.script_path.get().strip()
        if not sp:
            messagebox.showwarning("Missing", "Please select a Python script.")
            return False
        if not os.path.isfile(sp):
            messagebox.showerror("Error", "Selected script does not exist.")
            return False
        out = self.output_dir.get().strip()
        if out and not os.path.isdir(out):
            try: os.makedirs(out, exist_ok=True)
            except Exception as e:
                messagebox.showerror("Error", f"Cannot create output dir:\n{e}")
                return False
        return True

    def _split_comma_lines(self, s: str):
        raw=[]
        for part in s.replace(",", "\n").splitlines():
            part = part.strip()
            if part: raw.append(part)
        return raw

    def _smart_split(self, s: str):
        out, buf, q = [], "", None
        for ch in s:
            if q:
                if ch == q: q=None
                else: buf += ch
            else:
                if ch in ("'", '"'): q=ch
                elif ch.isspace():
                    if buf: out.append(buf); buf=""
                else: buf += ch
        if buf: out.append(buf)
        return out

    def _build_command(self):
        self._sync_textvars()
        inv = get_pyinstaller_invocation()
        args = inv[:]  # start with pyinstaller invocation

        # options BEFORE script
        if self.onefile.get():   args.append("-F")
        if self.noconsole.get(): args.append("--noconsole")
        if self.clean.get():     args.append("--clean")
        if self.strip.get():     args.append("--strip")
        if self.upx.get():       args.append("--upx-dir") or None  # will rely on PATH
        if self.uac_admin.get() and IS_WINDOWS: args.append("--uac-admin")
        if self.name.get().strip(): args += ["-n", self.name.get().strip()]
        if self.icon_path.get().strip(): args += ["-i", self.icon_path.get().strip()]
        if self.output_dir.get().strip(): args += ["--distpath", self.output_dir.get().strip()]
        if self.version_file.get().strip(): args += ["--version-file", self.version_file.get().strip()]

        # add-data
        for ln in [ln.strip() for ln in self.add_data.get().splitlines() if ln.strip()]:
            if "|" in ln:
                src, dest = [p.strip() for p in ln.split("|", 1)]
                if src:
                    pair = f"{src}{ADD_DATA_SEP}{dest or '.'}"
                    args += ["--add-data", pair]

        # hidden imports
        for mod in self._split_comma_lines(self.hidden_imports.get()):
            args += ["--hidden-import", mod]

        # extra paths
        for pth in self._split_comma_lines(self.extra_paths.get()):
            args += ["--paths", pth]

        # extra raw args
        if self.extra_args.get().strip():
            args += self._smart_split(self.extra_args.get().strip())

        # script LAST
        args.append(self.script_path.get().strip())

        return args

    def start_build(self):
        if self.is_building:
            messagebox.showinfo("Busy", "A build is already running."); return
        if not self._validate(): return

        # quick check PyInstaller presence
        try:
            subprocess.check_output(get_pyinstaller_invocation() + ["--version"], text=True, stderr=subprocess.STDOUT)
        except Exception:
            py_cmd = find_python_command()
            messagebox.showerror("PyInstaller missing",
                                 f"PyInstaller is not installed in this Python environment.\n\nInstall with:\n  {py_cmd} -m pip install pyinstaller")
            self._log("Aborting: PyInstaller not installed.")
            return

        cmd = self._build_command()
        self._log("---- PyInstaller Command ----")
        self._log(" ".join(cmd))
        self._log("-----------------------------")

        self.is_building = True
        t = threading.Thread(target=self._run_build, args=(cmd,), daemon=True)
        t.start()

    def cancel_build(self):
        if self.is_building and self.process and self.process.poll() is None:
            try:
                self.process.terminate()
                self._log("Build process terminated by user.")
            except Exception as e:
                self._log(f"Failed to terminate process: {e}")
        else:
            self._log("No active build to cancel.")

    def _run_build(self, cmd):
        try:
            self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, shell=False)
            for line in self.process.stdout:
                self._q.put(line.rstrip("\n"))
            rc = self.process.wait()
            if rc == 0:
                self._q.put("✅ Build finished successfully.")
            else:
                self._q.put(f"❌ Build failed with exit code {rc}.")
        except FileNotFoundError:
            self._q.put("❌ PyInstaller executable not found.")
        except Exception as e:
            self._q.put(f"❌ Error: {e}")
        finally:
            self.is_building = False
            self.process = None

if __name__ == "__main__":
    app = ExeBuilderApp()
    try:
        from ctypes import windll
        windll.shcore.SetProcessDpiAwareness(1)
    except Exception:
        pass
    style = ttk.Style()
    try:
        if IS_WINDOWS: style.theme_use("vista")
        else: style.theme_use("clam")
    except Exception:
        pass
    app.mainloop()

Briefly explained (English UI)
Script: Select your main .py script.

Name: Output name of the app.

Icon: .ico for the EXE icon.

Output: Destination folder for dist/.

One-file: A single EXE (-F). Disable for one-folder.

Windowed: Without console (for GUI apps).

Additional data: Per line source_path|dest_folder (internal destination in the bundle). The separator is automatically set correctly (; on Windows, : otherwise).

Hidden imports: Modules that PyInstaller doesn’t automatically find.

Extra paths: Additional search paths for imports.

Version file: VSVersionInfo file for resources/metadata (optional).

UAC Admin: EXE with admin request (Windows only).

UPX: Enables UPX compression (UPX must be installed/detectable).

Extra PyInstaller args: Any additional flags (e.g., –add-binary, –collect-all somepkg, …).

Presets: Save/load all settings as .json.

Build: Starts PyInstaller and displays the live output at the bottom of the log.

Cancel Build: Aborts the current build process.

Tips
Version Info: You can use a version.txt file according to the PyInstaller schema (VSVersionInfo). You can find an example in the PyInstaller documentation (FileVersion, CompanyName, etc. fields).

Data/Assets: For folders, specify assets|assets, for example, so that the folder in the bundle is named assets/.

GUI Apps: Set “Windowed (no console)” if you don’t want a console.

Troubleshooting: If modules are missing, add them to Hidden imports or use –collect-all packagename in Extra args.

0 Votes: 0 Upvotes, 0 Downvotes (0 Points)

Leave a reply

Loading Next Post...
Loading

Signing-in 3 seconds...