393 lines
14 KiB
Python
393 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Simple Script Builder for PyInstaller GUI Wrapper.
|
|
Handles building executables from single Python scripts.
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox
|
|
import pathlib
|
|
import subprocess
|
|
import sys
|
|
import os
|
|
import threading
|
|
from typing import Optional, Callable
|
|
|
|
|
|
class SimpleScriptBuilder:
|
|
"""
|
|
Manages the simple script building interface and process.
|
|
Allows users to select a single Python script and build it into an executable.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
parent_frame: ttk.Frame,
|
|
logger_func: Optional[Callable[..., None]] = None,
|
|
):
|
|
"""
|
|
Initializes the SimpleScriptBuilder.
|
|
|
|
Args:
|
|
parent_frame: The parent Tkinter frame where this interface will be placed.
|
|
logger_func: A function to use for logging messages.
|
|
"""
|
|
self.parent_frame = parent_frame
|
|
self.logger = logger_func if logger_func else self._default_logger
|
|
|
|
# Variables
|
|
self.script_path_var = tk.StringVar()
|
|
self.output_dir_var = tk.StringVar()
|
|
self.onefile_var = tk.BooleanVar(value=True)
|
|
self.is_building = False
|
|
|
|
self._create_widgets()
|
|
self._layout_widgets()
|
|
|
|
def _default_logger(self, message: str, level: str = "INFO") -> None:
|
|
"""Default logger if none is provided."""
|
|
print(f"[{level}] {message}")
|
|
|
|
def _log(self, message: str, level: str = "INFO") -> None:
|
|
"""Helper for logging messages with a consistent prefix."""
|
|
try:
|
|
self.logger(f"[SimpleScriptBuilder] {message}", level=level)
|
|
except TypeError:
|
|
self.logger(f"[{level}][SimpleScriptBuilder] {message}")
|
|
|
|
def _create_widgets(self) -> None:
|
|
"""Creates all widgets for the simple script builder interface."""
|
|
# Script selection frame
|
|
self.script_frame = ttk.LabelFrame(self.parent_frame, text="Python Script Selection", padding="10")
|
|
|
|
self.script_label = ttk.Label(self.script_frame, text="Select Python Script:")
|
|
self.script_entry = ttk.Entry(self.script_frame, textvariable=self.script_path_var, width=70)
|
|
self.script_browse_btn = ttk.Button(
|
|
self.script_frame,
|
|
text="Browse...",
|
|
command=self._browse_script
|
|
)
|
|
|
|
# Options frame
|
|
self.options_frame = ttk.LabelFrame(self.parent_frame, text="Build Options", padding="10")
|
|
|
|
self.onefile_check = ttk.Checkbutton(
|
|
self.options_frame,
|
|
text="Create single file executable (--onefile)",
|
|
variable=self.onefile_var
|
|
)
|
|
|
|
# Output directory frame
|
|
self.output_frame = ttk.LabelFrame(self.parent_frame, text="Output Directory", padding="10")
|
|
|
|
self.output_label = ttk.Label(self.output_frame, text="Output Directory:")
|
|
self.output_entry = ttk.Entry(self.output_frame, textvariable=self.output_dir_var, width=70)
|
|
self.output_browse_btn = ttk.Button(
|
|
self.output_frame,
|
|
text="Browse...",
|
|
command=self._browse_output_dir
|
|
)
|
|
|
|
# Action buttons frame
|
|
self.action_frame = ttk.Frame(self.parent_frame, padding="10")
|
|
|
|
self.build_btn = ttk.Button(
|
|
self.action_frame,
|
|
text="Build Executable",
|
|
command=self._start_build,
|
|
state="disabled"
|
|
)
|
|
|
|
self.open_folder_btn = ttk.Button(
|
|
self.action_frame,
|
|
text="Open Output Folder",
|
|
command=self._open_output_folder,
|
|
state="disabled"
|
|
)
|
|
|
|
def _layout_widgets(self) -> None:
|
|
"""Layouts all widgets in the parent frame."""
|
|
# Script frame
|
|
self.script_frame.pack(fill=tk.X, padx=5, pady=5)
|
|
self.script_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
|
self.script_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
|
|
self.script_browse_btn.grid(row=0, column=2, padx=5, pady=5)
|
|
self.script_frame.columnconfigure(1, weight=1)
|
|
|
|
# Options frame
|
|
self.options_frame.pack(fill=tk.X, padx=5, pady=5)
|
|
self.onefile_check.pack(anchor="w", padx=5, pady=5)
|
|
|
|
# Output frame
|
|
self.output_frame.pack(fill=tk.X, padx=5, pady=5)
|
|
self.output_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
|
self.output_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
|
|
self.output_browse_btn.grid(row=0, column=2, padx=5, pady=5)
|
|
self.output_frame.columnconfigure(1, weight=1)
|
|
|
|
# Action buttons
|
|
self.action_frame.pack(fill=tk.X, padx=5, pady=10)
|
|
self.build_btn.pack(side=tk.LEFT, padx=5)
|
|
self.open_folder_btn.pack(side=tk.LEFT, padx=5)
|
|
|
|
def _browse_script(self) -> None:
|
|
"""Opens a file dialog to select a Python script."""
|
|
file_path = filedialog.askopenfilename(
|
|
title="Select Python Script",
|
|
filetypes=[
|
|
("Python files", "*.py"),
|
|
("Python files (no console)", "*.pyw"),
|
|
("All files", "*.*")
|
|
],
|
|
parent=self.parent_frame
|
|
)
|
|
|
|
if file_path:
|
|
self.script_path_var.set(file_path)
|
|
self._log(f"Selected script: {file_path}", level="INFO")
|
|
|
|
# Auto-suggest output directory (same directory as script)
|
|
if not self.output_dir_var.get():
|
|
script_dir = str(pathlib.Path(file_path).parent)
|
|
self.output_dir_var.set(script_dir)
|
|
self._log(f"Auto-set output directory: {script_dir}", level="DEBUG")
|
|
|
|
self._update_build_button_state()
|
|
|
|
def _browse_output_dir(self) -> None:
|
|
"""Opens a dialog to select the output directory."""
|
|
dir_path = filedialog.askdirectory(
|
|
title="Select Output Directory",
|
|
parent=self.parent_frame
|
|
)
|
|
|
|
if dir_path:
|
|
self.output_dir_var.set(dir_path)
|
|
self._log(f"Selected output directory: {dir_path}", level="INFO")
|
|
self._update_build_button_state()
|
|
|
|
def _update_build_button_state(self) -> None:
|
|
"""Enables or disables the build button based on input validity."""
|
|
script_path = self.script_path_var.get()
|
|
output_dir = self.output_dir_var.get()
|
|
|
|
if script_path and pathlib.Path(script_path).is_file() and output_dir:
|
|
self.build_btn.config(state="normal")
|
|
else:
|
|
self.build_btn.config(state="disabled")
|
|
|
|
def _start_build(self) -> None:
|
|
"""Starts the build process in a separate thread."""
|
|
if self.is_building:
|
|
self._log("Build already in progress!", level="WARNING")
|
|
return
|
|
|
|
script_path = self.script_path_var.get()
|
|
output_dir = self.output_dir_var.get()
|
|
|
|
if not script_path or not pathlib.Path(script_path).is_file():
|
|
messagebox.showerror(
|
|
"Invalid Script",
|
|
"Please select a valid Python script file.",
|
|
parent=self.parent_frame
|
|
)
|
|
return
|
|
|
|
if not output_dir:
|
|
messagebox.showerror(
|
|
"Invalid Output Directory",
|
|
"Please select an output directory.",
|
|
parent=self.parent_frame
|
|
)
|
|
return
|
|
|
|
self.is_building = True
|
|
self.build_btn.config(state="disabled")
|
|
self._log("=" * 50, level="INFO")
|
|
self._log("STARTING SIMPLE SCRIPT BUILD", level="INFO")
|
|
self._log("=" * 50, level="INFO")
|
|
|
|
# Run build in a separate thread to avoid freezing the GUI
|
|
build_thread = threading.Thread(
|
|
target=self._run_build_process,
|
|
args=(script_path, output_dir),
|
|
daemon=True
|
|
)
|
|
build_thread.start()
|
|
|
|
def _run_build_process(self, script_path: str, output_dir: str) -> None:
|
|
"""
|
|
Runs the PyInstaller build process.
|
|
|
|
Args:
|
|
script_path: Path to the Python script to build.
|
|
output_dir: Directory where build and dist folders will be created.
|
|
"""
|
|
try:
|
|
script_path_obj = pathlib.Path(script_path)
|
|
output_dir_obj = pathlib.Path(output_dir)
|
|
|
|
# Create output directory if it doesn't exist
|
|
if not output_dir_obj.exists():
|
|
self._log(f"Creating output directory: {output_dir_obj}", level="INFO")
|
|
try:
|
|
output_dir_obj.mkdir(parents=True, exist_ok=True)
|
|
self._log(f"Output directory created successfully", level="INFO")
|
|
except Exception as dir_err:
|
|
self._log(f"Failed to create output directory: {dir_err}", level="ERROR")
|
|
error_msg = f"Cannot create output directory:\n{output_dir_obj}\n\nError: {dir_err}"
|
|
self.parent_frame.after(
|
|
0,
|
|
lambda err=error_msg: messagebox.showerror(
|
|
"Directory Creation Failed",
|
|
err,
|
|
parent=self.parent_frame
|
|
)
|
|
)
|
|
return
|
|
elif not output_dir_obj.is_dir():
|
|
error_msg = f"The specified path exists but is not a directory:\n{output_dir_obj}"
|
|
self._log(error_msg, level="ERROR")
|
|
self.parent_frame.after(
|
|
0,
|
|
lambda err=error_msg: messagebox.showerror(
|
|
"Invalid Path",
|
|
err,
|
|
parent=self.parent_frame
|
|
)
|
|
)
|
|
return
|
|
|
|
# Prepare PyInstaller command
|
|
cmd = [sys.executable, "-m", "PyInstaller"]
|
|
|
|
# Add onefile option if selected
|
|
if self.onefile_var.get():
|
|
cmd.append("--onefile")
|
|
self._log("Building as single file executable", level="INFO")
|
|
else:
|
|
self._log("Building as directory bundle", level="INFO")
|
|
|
|
# Set output directories
|
|
build_dir = output_dir_obj / "_build"
|
|
dist_dir = output_dir_obj / "_dist"
|
|
|
|
cmd.extend([
|
|
"--distpath", str(dist_dir),
|
|
"--workpath", str(build_dir),
|
|
"--specpath", str(output_dir_obj),
|
|
])
|
|
|
|
# Add the script path
|
|
cmd.append(str(script_path_obj))
|
|
|
|
self._log(f"Executing: {' '.join(cmd)}", level="DEBUG")
|
|
self._log(f"Working directory: {output_dir_obj}", level="DEBUG")
|
|
|
|
# Run PyInstaller
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
cwd=str(output_dir_obj),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1
|
|
)
|
|
|
|
# Stream output to logger
|
|
if process.stdout:
|
|
for line in process.stdout:
|
|
line = line.rstrip()
|
|
if line:
|
|
self._log(line, level="INFO")
|
|
|
|
process.wait()
|
|
|
|
# Check result
|
|
if process.returncode == 0:
|
|
self._log("=" * 50, level="INFO")
|
|
self._log("BUILD COMPLETED SUCCESSFULLY!", level="INFO")
|
|
self._log(f"Output location: {dist_dir}", level="INFO")
|
|
self._log("=" * 50, level="INFO")
|
|
|
|
# Enable open folder button
|
|
self.open_folder_btn.config(state="normal")
|
|
|
|
# Show success message in main thread
|
|
self.parent_frame.after(
|
|
0,
|
|
lambda d=str(dist_dir): messagebox.showinfo(
|
|
"Build Successful",
|
|
f"Executable built successfully!\n\nOutput: {d}",
|
|
parent=self.parent_frame
|
|
)
|
|
)
|
|
else:
|
|
self._log("=" * 50, level="ERROR")
|
|
self._log(f"BUILD FAILED! Exit code: {process.returncode}", level="ERROR")
|
|
self._log("=" * 50, level="ERROR")
|
|
|
|
# Show error message in main thread
|
|
self.parent_frame.after(
|
|
0,
|
|
lambda rc=process.returncode: messagebox.showerror(
|
|
"Build Failed",
|
|
f"Build process failed with exit code {rc}.\n\nCheck the log for details.",
|
|
parent=self.parent_frame
|
|
)
|
|
)
|
|
|
|
except Exception as e:
|
|
self._log(f"Error during build: {e}", level="ERROR")
|
|
import traceback
|
|
self._log(traceback.format_exc(), level="ERROR")
|
|
|
|
# Show error message in main thread
|
|
error_msg = str(e)
|
|
self.parent_frame.after(
|
|
0,
|
|
lambda err=error_msg: messagebox.showerror(
|
|
"Build Error",
|
|
f"An error occurred during the build:\n{err}",
|
|
parent=self.parent_frame
|
|
)
|
|
)
|
|
finally:
|
|
self.is_building = False
|
|
# Re-enable build button in main thread
|
|
self.parent_frame.after(0, self._update_build_button_state)
|
|
|
|
def _open_output_folder(self) -> None:
|
|
"""Opens the output folder in the system file explorer."""
|
|
output_dir = self.output_dir_var.get()
|
|
|
|
if not output_dir or not pathlib.Path(output_dir).is_dir():
|
|
messagebox.showerror(
|
|
"Invalid Directory",
|
|
"Output directory does not exist.",
|
|
parent=self.parent_frame
|
|
)
|
|
return
|
|
|
|
dist_dir = pathlib.Path(output_dir) / "_dist"
|
|
|
|
# Use the dist directory if it exists, otherwise use the output directory
|
|
folder_to_open = dist_dir if dist_dir.is_dir() else pathlib.Path(output_dir)
|
|
|
|
try:
|
|
if sys.platform == "win32":
|
|
os.startfile(str(folder_to_open))
|
|
elif sys.platform == "darwin":
|
|
subprocess.run(["open", str(folder_to_open)])
|
|
else:
|
|
subprocess.run(["xdg-open", str(folder_to_open)])
|
|
|
|
self._log(f"Opened folder: {folder_to_open}", level="INFO")
|
|
except Exception as e:
|
|
self._log(f"Failed to open folder: {e}", level="ERROR")
|
|
messagebox.showerror(
|
|
"Error",
|
|
f"Failed to open folder:\n{e}",
|
|
parent=self.parent_frame
|
|
)
|