244 lines
9.1 KiB
Python
244 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Simple Tkinter GUI to run PyInstaller and make an exe from a .py file.
|
|
|
|
Features:
|
|
- choose script
|
|
- choose onefile / dir
|
|
- choose windowed (noconsole)
|
|
- add icon
|
|
- add additional data (file or folder; multiple, separated by semicolons)
|
|
- set output folder
|
|
- show live PyInstaller output
|
|
- cancel build
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import filedialog, messagebox, scrolledtext
|
|
import subprocess
|
|
import threading
|
|
import sys
|
|
import shlex
|
|
import os
|
|
from pathlib import Path
|
|
|
|
class BuilderGUI(tk.Tk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.title("Python → EXE (PyInstaller GUI)")
|
|
self.geometry("800x600")
|
|
self.create_widgets()
|
|
self.proc = None # subprocess handle
|
|
self.stop_requested = False
|
|
|
|
def create_widgets(self):
|
|
frame_top = tk.Frame(self)
|
|
frame_top.pack(fill="x", padx=10, pady=8)
|
|
|
|
tk.Label(frame_top, text="Script:").grid(row=0, column=0, sticky="e")
|
|
self.script_entry = tk.Entry(frame_top, width=70)
|
|
self.script_entry.grid(row=0, column=1, padx=6)
|
|
tk.Button(frame_top, text="Browse...", command=self.browse_script).grid(row=0, column=2)
|
|
|
|
tk.Label(frame_top, text="Output folder:").grid(row=1, column=0, sticky="e")
|
|
self.out_entry = tk.Entry(frame_top, width=70)
|
|
self.out_entry.grid(row=1, column=1, padx=6)
|
|
tk.Button(frame_top, text="Choose...", command=self.choose_output).grid(row=1, column=2)
|
|
|
|
opts_frame = tk.LabelFrame(self, text="Options", padx=8, pady=8)
|
|
opts_frame.pack(fill="x", padx=10)
|
|
|
|
self.onefile_var = tk.BooleanVar(value=True)
|
|
tk.Checkbutton(opts_frame, text="Onefile (single exe)", variable=self.onefile_var).grid(row=0, column=0, sticky="w", padx=6, pady=2)
|
|
|
|
self.windowed_var = tk.BooleanVar(value=False)
|
|
tk.Checkbutton(opts_frame, text="Windowed (no console) / --noconsole", variable=self.windowed_var).grid(row=0, column=1, sticky="w", padx=6, pady=2)
|
|
|
|
tk.Label(opts_frame, text="Icon (.ico):").grid(row=1, column=0, sticky="e")
|
|
self.icon_entry = tk.Entry(opts_frame, width=50)
|
|
self.icon_entry.grid(row=1, column=1, sticky="w", padx=6)
|
|
tk.Button(opts_frame, text="Browse", command=self.browse_icon).grid(row=1, column=2)
|
|
|
|
tk.Label(opts_frame, text="Additional data (src;dest pairs separated by ';', e.g. resources;resources):").grid(row=2, column=0, columnspan=3, sticky="w", pady=(6,0))
|
|
self.data_entry = tk.Entry(opts_frame, width=110)
|
|
self.data_entry.grid(row=3, column=0, columnspan=3, padx=6, pady=4)
|
|
|
|
tk.Label(opts_frame, text="Extra PyInstaller args:").grid(row=4, column=0, sticky="w")
|
|
self.extra_entry = tk.Entry(opts_frame, width=80)
|
|
self.extra_entry.grid(row=4, column=1, columnspan=2, padx=6, pady=4, sticky="w")
|
|
|
|
run_frame = tk.Frame(self)
|
|
run_frame.pack(fill="x", padx=10, pady=8)
|
|
|
|
self.build_btn = tk.Button(run_frame, text="Build EXE", command=self.start_build, bg="#2b7a78", fg="white")
|
|
self.build_btn.pack(side="left", padx=(0,6))
|
|
|
|
self.cancel_btn = tk.Button(run_frame, text="Cancel", command=self.request_cancel, state="disabled", bg="#b00020", fg="white")
|
|
self.cancel_btn.pack(side="left")
|
|
|
|
clear_btn = tk.Button(run_frame, text="Clear Log", command=self.clear_log)
|
|
clear_btn.pack(side="left", padx=6)
|
|
|
|
open_out_btn = tk.Button(run_frame, text="Open Output Folder", command=self.open_output)
|
|
open_out_btn.pack(side="right")
|
|
|
|
self.log = scrolledtext.ScrolledText(self, height=18, font=("Consolas", 10))
|
|
self.log.pack(fill="both", expand=True, padx=10, pady=(0,10))
|
|
|
|
def browse_script(self):
|
|
path = filedialog.askopenfilename(filetypes=[("Python files", "*.py")])
|
|
if path:
|
|
self.script_entry.delete(0, tk.END)
|
|
self.script_entry.insert(0, path)
|
|
# default output to script parent /dist
|
|
parent = os.path.dirname(path)
|
|
default_out = os.path.join(parent, "dist")
|
|
self.out_entry.delete(0, tk.END)
|
|
self.out_entry.insert(0, default_out)
|
|
|
|
def choose_output(self):
|
|
path = filedialog.askdirectory()
|
|
if path:
|
|
self.out_entry.delete(0, tk.END)
|
|
self.out_entry.insert(0, path)
|
|
|
|
def browse_icon(self):
|
|
path = filedialog.askopenfilename(filetypes=[("Icon files", "*.ico")])
|
|
if path:
|
|
self.icon_entry.delete(0, tk.END)
|
|
self.icon_entry.insert(0, path)
|
|
|
|
def clear_log(self):
|
|
self.log.delete("1.0", tk.END)
|
|
|
|
def open_output(self):
|
|
out = self.out_entry.get().strip()
|
|
if not out:
|
|
messagebox.showinfo("Output folder", "No output folder set.")
|
|
return
|
|
os.startfile(out) if os.name == "nt" else subprocess.run(["xdg-open", out])
|
|
|
|
def request_cancel(self):
|
|
if self.proc and self.proc.poll() is None:
|
|
self.stop_requested = True
|
|
# try terminate politely
|
|
try:
|
|
self.proc.terminate()
|
|
except Exception:
|
|
pass
|
|
self.log_insert("\nCancellation requested...\n")
|
|
self.cancel_btn.config(state="disabled")
|
|
|
|
def log_insert(self, text):
|
|
self.log.insert(tk.END, text)
|
|
self.log.see(tk.END)
|
|
|
|
def start_build(self):
|
|
script = self.script_entry.get().strip()
|
|
if not script or not os.path.isfile(script):
|
|
messagebox.showerror("Error", "Please choose a valid Python script to build.")
|
|
return
|
|
|
|
out_dir = self.out_entry.get().strip() or os.path.dirname(script)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
self.build_btn.config(state="disabled")
|
|
self.cancel_btn.config(state="normal")
|
|
self.stop_requested = False
|
|
self.clear_log()
|
|
|
|
# Start build on a thread to keep UI responsive
|
|
thread = threading.Thread(target=self.run_pyinstaller, args=(script, out_dir), daemon=True)
|
|
thread.start()
|
|
|
|
def run_pyinstaller(self, script, out_dir):
|
|
# Build the PyInstaller command
|
|
cmd = [sys.executable, "-m", "PyInstaller"]
|
|
|
|
if self.onefile_var.get():
|
|
cmd.append("--onefile")
|
|
else:
|
|
cmd.append("--onedir")
|
|
|
|
if self.windowed_var.get():
|
|
cmd.append("--noconsole")
|
|
|
|
icon = self.icon_entry.get().strip()
|
|
if icon:
|
|
cmd.extend(["--icon", icon])
|
|
|
|
# add additional data: user can provide pairs like "data;data" or "assets;assets"
|
|
data_spec = self.data_entry.get().strip()
|
|
if data_spec:
|
|
# support multiple separated by semicolons or vertical bars
|
|
pairs = [p for p in (data_spec.split(";") + data_spec.split("|")) if p.strip()]
|
|
# normalize pairs to PyInstaller format: src;dest (on Windows use ';' in CLI but PyInstaller expects src;dest as single argument)
|
|
for p in pairs:
|
|
# if user typed "src:dest" or "src->dest", replace with semicolon
|
|
p_fixed = p.replace(":", ";").replace("->", ";")
|
|
cmd.extend(["--add-data", p_fixed])
|
|
|
|
# user extra args (raw)
|
|
extra = self.extra_entry.get().strip()
|
|
if extra:
|
|
# split carefully
|
|
cmd.extend(shlex.split(extra))
|
|
|
|
# ensure output path goes to chosen dir: use --distpath
|
|
cmd.extend(["--distpath", out_dir])
|
|
|
|
# entry script
|
|
cmd.append(script)
|
|
|
|
self.log_insert("Running PyInstaller with command:\n" + " ".join(shlex.quote(c) for c in cmd) + "\n\n")
|
|
|
|
# spawn the process
|
|
try:
|
|
self.proc = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
bufsize=1,
|
|
universal_newlines=True,
|
|
)
|
|
except Exception as e:
|
|
self.log_insert(f"Failed to start PyInstaller: {e}\n")
|
|
self.build_btn.config(state="normal")
|
|
self.cancel_btn.config(state="disabled")
|
|
return
|
|
|
|
# Stream output line by line
|
|
try:
|
|
for line in self.proc.stdout:
|
|
if line:
|
|
self.log_insert(line)
|
|
if self.stop_requested:
|
|
try:
|
|
self.proc.terminate()
|
|
except Exception:
|
|
pass
|
|
break
|
|
self.proc.wait(timeout=30)
|
|
except subprocess.TimeoutExpired:
|
|
self.log_insert("Process did not exit in time after termination.\n")
|
|
except Exception as e:
|
|
self.log_insert(f"Error while running PyInstaller: {e}\n")
|
|
|
|
retcode = self.proc.returncode if self.proc else None
|
|
if self.stop_requested:
|
|
self.log_insert("\nBuild cancelled by user.\n")
|
|
elif retcode == 0:
|
|
self.log_insert("\nBuild finished successfully.\n")
|
|
else:
|
|
self.log_insert(f"\nBuild finished with return code {retcode}.\n")
|
|
|
|
# Re-enable buttons
|
|
self.build_btn.config(state="normal")
|
|
self.cancel_btn.config(state="disabled")
|
|
self.proc = None
|
|
self.stop_requested = False
|
|
|
|
if __name__ == "__main__":
|
|
app = BuilderGUI()
|
|
app.mainloop()
|