# -*- 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 )