SXXXXXXX_PyInstallerGUIWrapper/pyinstallerguiwrapper/gui.py
2025-05-05 15:48:58 +02:00

1128 lines
62 KiB
Python

# -*- coding: utf-8 -*-
""" gui.py - Main GUI module for the PyInstaller Wrapper. """
import tkinter as tk
from tkinter import ttk
from tkinter import filedialog
from tkinter import messagebox
from tkinter import scrolledtext
import tkinter.simpledialog as simpledialog
import os
import sys
import threading
import queue
import traceback
import shutil
import pathlib
import subprocess # Needed for git commands
import datetime # Needed for build timestamp
import re # Needed for function generation (tag extraction, placeholder matching)
# --- Import Version Info FOR THE WRAPPER ITSELF ---
try:
# Use absolute import based on package name
from pyinstallerguiwrapper import _version as wrapper_version
WRAPPER_APP_VERSION_STRING = f"{wrapper_version.__version__} ({wrapper_version.GIT_BRANCH}/{wrapper_version.GIT_COMMIT_HASH[:7]})"
WRAPPER_BUILD_INFO = f"Wrapper Built: {wrapper_version.BUILD_TIMESTAMP}"
except ImportError:
# This might happen if you run the wrapper directly from source
# without generating its _version.py first (if you use that approach for the wrapper itself)
WRAPPER_APP_VERSION_STRING = "(Dev Wrapper)"
WRAPPER_BUILD_INFO = "Wrapper build time unknown"
# --- End Import Version Info ---
# Import other modules from the package (using absolute imports)
from pyinstallerguiwrapper import config
from pyinstallerguiwrapper import spec_parser
from pyinstallerguiwrapper import builder
# --- Constants for Version Generation ---
DEFAULT_VERSION = "0.0.0+unknown"
DEFAULT_COMMIT = "Unknown"
DEFAULT_BRANCH = "Unknown"
# --- End Constants ---
class PyInstallerGUI(tk.Tk):
""" Main application window for the PyInstaller GUI tool. """
def __init__(self):
""" Initialize the main application window, variables, and widgets. """
super().__init__()
# Use the Wrapper's version in the title
self.title(f"PyInstaller GUI Wrapper - {WRAPPER_APP_VERSION_STRING}")
# --- Project Path ---
self.project_directory_path = tk.StringVar() # Stores the selected main project folder
# --- Derived Paths (Internal state, might be displayed) ---
self._derived_spec_path = ""
self._derived_icon_path = ""
self._derived_main_script_path = ""
self._derived_source_dir_path = None # Will store a Path object
self._project_root_name = "" # e.g., "MyTool"
# --- Tkinter Variables ---
self.icon_path = tk.StringVar() # Holds the DERIVED or user-overridden icon path
self.app_name = tk.StringVar() # Derived or overridden in GUI/spec
self.is_onefile = tk.BooleanVar(value=config.DEFAULT_SPEC_OPTIONS['onefile'])
self.is_windowed = tk.BooleanVar(value=config.DEFAULT_SPEC_OPTIONS['windowed'])
self.log_level = tk.StringVar(value=config.DEFAULT_SPEC_OPTIONS['log_level'])
# --- Data Structures for Complex Options ---
self.added_data_list = []
# --- Build Process State ---
self.build_thread = None
self.build_queue = queue.Queue()
# --- Initialize GUI ---
self.main_frame = ttk.Frame(self, padding="10")
self.main_frame.grid(row=0, column=0, sticky="nsew")
self.rowconfigure(0, weight=1)
self.columnconfigure(0, weight=1)
self._create_widgets(self.main_frame)
self._layout_widgets()
# Start monitoring the build queue
self.after(100, self._check_build_queue)
self._log_to_gui(f"Running {WRAPPER_APP_VERSION_STRING}", level="INFO")
self._log_to_gui(f"{WRAPPER_BUILD_INFO}", level="DEBUG")
self._log_to_gui("Application initialized. Select the project directory.", level="INFO")
# --- Helper Method for Running Git Commands ---
def _run_git_command_in_dir(self, command, target_dir):
""" Executes a Git command in a specific directory. """
self._log_to_gui(f"Running Git command in '{target_dir}': {command}", level="DEBUG")
try:
# Execute command in the target directory
process = subprocess.run(
command,
shell=True, # Use shell=True cautiously, ok here as command is internal
capture_output=True,
text=True,
cwd=target_dir, # CRITICAL: Run in the target project's directory
check=False, # Don't raise exception on non-zero exit code
errors='ignore' # Handle potential decoding errors
)
if process.returncode == 0:
return process.stdout.strip() # Return successful output
else:
# Log failures but don't make version generation fatal unless necessary
self._log_to_gui(f"Git command failed (rc={process.returncode}) in '{target_dir}': '{command}'. Stderr: {process.stderr.strip()}", level="WARNING")
return None
except FileNotFoundError:
# Log if git command itself is not found
self._log_to_gui("Git command failed: 'git' not found. Is Git installed and in PATH?", level="ERROR")
return None # Indicate git is likely missing
except Exception as e:
# Log other potential errors during subprocess execution
self._log_to_gui(f"Error running git command '{command}' in '{target_dir}': {e}", level="ERROR")
self._log_to_gui(traceback.format_exc(), level="DEBUG")
return None
# --- Method to Generate Target Project's _version.py ---
def _generate_target_version_file(self, project_dir, target_version_file_path):
"""
Checks if project_dir is a git repo, gathers version info,
and writes it to target_version_file_path, including a helper function.
Writes defaults if not a git repo or on error.
Returns True if file was written, False on critical write error.
"""
self._log_to_gui(f"Attempting to generate version file for target: {target_version_file_path}", level="INFO")
# Initialize with default values
version_info = {
"version": DEFAULT_VERSION,
"commit": DEFAULT_COMMIT,
"branch": DEFAULT_BRANCH,
}
is_git_repo = False
# Check if the target directory is a git repository
git_check = self._run_git_command_in_dir("git rev-parse --is-inside-work-tree", project_dir)
if git_check == "true":
# If it is a Git repo, try to fetch version details
is_git_repo = True
self._log_to_gui(f"Project directory '{project_dir}' identified as a Git repository.", level="INFO")
# Get version string (tag, or commit if no tag, plus dirty status)
describe_output = self._run_git_command_in_dir("git describe --tags --always --dirty", project_dir)
if describe_output:
version_info["version"] = describe_output
# Get full commit hash
commit_output = self._run_git_command_in_dir("git rev-parse HEAD", project_dir)
if commit_output:
version_info["commit"] = commit_output
# Get current branch name, handling detached HEAD
branch_output = self._run_git_command_in_dir("git rev-parse --abbrev-ref HEAD", project_dir)
if branch_output and branch_output != 'HEAD':
version_info["branch"] = branch_output
elif branch_output == 'HEAD' and version_info["commit"] != DEFAULT_COMMIT:
version_info["branch"] = f"detached@{version_info['commit'][:7]}" # Show short commit in detached state
else:
# Log if not a git repo or if the check failed
self._log_to_gui(f"Project directory '{project_dir}' is not a Git repository or Git failed. Using default version info.", level="WARNING")
# Get current timestamp for the build
timestamp = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
# --- Define the Function to be Embedded in _version.py ---
# MODIFIED: Use raw f-string (rf''') to avoid SyntaxWarnings for '\s'
# MODIFIED: Escape braces in the comment: {{word}}
get_version_function_code = rf'''
import re
# --- Version Data (Generated) ---
# This section is automatically generated by the build process.
__version__ = "{version_info['version']}"
GIT_COMMIT_HASH = "{version_info['commit']}"
GIT_BRANCH = "{version_info['branch']}"
BUILD_TIMESTAMP = "{timestamp}"
IS_GIT_REPO = {is_git_repo}
# --- Default Values (for comparison or fallback) ---
DEFAULT_VERSION = "{DEFAULT_VERSION}"
DEFAULT_COMMIT = "{DEFAULT_COMMIT}"
DEFAULT_BRANCH = "{DEFAULT_BRANCH}"
# --- Helper Function ---
def get_version_string(format_string=None):
"""
Returns a formatted string based on the build version information.
Args:
format_string (str, optional): A format string using placeholders.
Defaults to "{{{{version}}}} ({{{{branch}}}}/{{{{commit_short}}}})" if None.
Placeholders:
{{{{version}}}}: Full version string (e.g., 'v1.0.0-5-gabcdef-dirty')
{{{{tag}}}}: Clean tag part if exists (e.g., 'v1.0.0'), else DEFAULT_VERSION.
{{{{commit}}}}: Full Git commit hash.
{{{{commit_short}}}}: Short Git commit hash (7 chars).
{{{{branch}}}}: Git branch name.
{{{{dirty}}}}: '-dirty' if the repo was dirty, empty otherwise.
{{{{timestamp}}}}: Full build timestamp (ISO 8601 UTC).
{{{{timestamp_short}}}}: Build date only (YYYY-MM-DD).
{{{{is_git}}}}: 'Git' if IS_GIT_REPO is True, 'Unknown' otherwise.
Returns:
str: The formatted version string, or an error message if formatting fails.
"""
if format_string is None:
format_string = "{{version}} ({{branch}}/{{commit_short}})" # Sensible default
replacements = {{}}
try:
# Prepare data dictionary for substitution
replacements['version'] = __version__ if __version__ else DEFAULT_VERSION
replacements['commit'] = GIT_COMMIT_HASH if GIT_COMMIT_HASH else DEFAULT_COMMIT
replacements['commit_short'] = GIT_COMMIT_HASH[:7] if GIT_COMMIT_HASH and len(GIT_COMMIT_HASH) >= 7 else DEFAULT_COMMIT
replacements['branch'] = GIT_BRANCH if GIT_BRANCH else DEFAULT_BRANCH
replacements['timestamp'] = BUILD_TIMESTAMP if BUILD_TIMESTAMP else "Unknown"
replacements['timestamp_short'] = BUILD_TIMESTAMP.split('T')[0] if BUILD_TIMESTAMP and 'T' in BUILD_TIMESTAMP else "Unknown"
replacements['is_git'] = "Git" if IS_GIT_REPO else "Unknown"
replacements['dirty'] = "-dirty" if __version__ and __version__.endswith('-dirty') else ""
# Extract clean tag using regex (handles versions like v1.0.0, 1.0.0)
tag = DEFAULT_VERSION
if __version__ and IS_GIT_REPO:
# Match optional 'v' prefix, then major.minor.patch
match = re.match(r'^(v?([0-9]+)\.([0-9]+)\.([0-9]+))', __version__)
if match:
tag = match.group(1) # Get the full tag (e.g., 'v1.0.0')
replacements['tag'] = tag
# Perform substitution using regex to find placeholders {{placeholder}}
output_string = format_string
# Iterate through placeholders and replace them in the format string
for placeholder, value in replacements.items():
# Compile regex pattern for {{placeholder}}, allowing for whitespace inside braces
pattern = re.compile(r'{{\s*' + re.escape(placeholder) + r'\s*}}')
# Substitute found patterns with the corresponding string value
output_string = pattern.sub(str(value), output_string)
# Optional: Check if any placeholders remain unsubstituted (could indicate typo)
if re.search(r'{{\s*[\w_]+\s*}}', output_string):
# You might want to log this or handle it, for now, we return the string as is
# print(f"Warning: Unsubstituted placeholders remain in version string: {{output_string}}")
pass
return output_string
except Exception as e:
# Return a simple error message in case of unexpected formatting issues
# Avoid printing directly from this generated function
return f"[Formatting Error: {{e}}]"
'''
# --- Combine Header and Function Code ---
# Standard header for generated files
content = f'''\
# -*- coding: utf-8 -*-
# File generated by PyInstaller GUI Wrapper. DO NOT EDIT MANUALLY.
# Contains build-time information scraped from Git (if available)
# and a helper function to format version strings.
{get_version_function_code}
'''
# --- Write the _version.py file ---
try:
# Ensure the target directory (source folder of the project being built) exists
os.makedirs(os.path.dirname(target_version_file_path), exist_ok=True)
# Write the generated content to the file
with open(target_version_file_path, 'w', encoding='utf-8') as f:
f.write(content)
# Log success
self._log_to_gui(f"Successfully generated/updated target version file (with function): {target_version_file_path}", level="INFO")
# Log the default formatted output for easy verification in the GUI log
if is_git_repo:
try:
default_output = self._get_default_formatted_version(version_info)
self._log_to_gui(f" -> Default format output: {default_output}", level="DEBUG")
except Exception as log_e:
self._log_to_gui(f" -> (Could not evaluate default format for logging: {log_e})", level="DEBUG")
return True # Indicate success
except Exception as e:
# Log critical error if writing fails and show an error to the user
self._log_to_gui(f"CRITICAL ERROR: Failed to write target version file '{target_version_file_path}': {e}", level="CRITICAL")
self._log_to_gui(traceback.format_exc(), level="DEBUG")
messagebox.showerror("Version File Error", f"Could not write the version file for the target project:\n{target_version_file_path}\n\nError: {e}\n\nBuild cannot proceed reliably.")
return False # Indicate failure
# Helper just for logging the default format output during generation
def _get_default_formatted_version(self, version_info):
""" Replicates the default format logic from the generated function for logging. """
v = version_info['version'] if version_info['version'] else DEFAULT_VERSION
b = version_info['branch'] if version_info['branch'] else DEFAULT_BRANCH
c = version_info['commit'][:7] if version_info['commit'] and len(version_info['commit']) >= 7 else DEFAULT_COMMIT
return f"{v} ({b}/{c})"
# --- Widget Creation ---
def _create_widgets(self, parent_frame):
# --- Section 1: Input Project Directory ---
self.project_frame = ttk.LabelFrame(parent_frame, text="Project Directory")
self.project_dir_label = ttk.Label(self.project_frame, text="Main Project Directory:")
self.project_dir_entry = ttk.Entry(self.project_frame, textvariable=self.project_directory_path, state="readonly", width=80)
self.project_dir_button_browse = ttk.Button(self.project_frame, text="Browse...", command=self._select_project_directory)
# --- Section 1.5: Derived Paths Display ---
self.derived_paths_frame = ttk.LabelFrame(parent_frame, text="Derived Paths (Automatic)")
self.derived_script_label_info = ttk.Label(self.derived_paths_frame, text="Script Found:")
self.derived_script_label_val = ttk.Label(self.derived_paths_frame, text="N/A", foreground="grey", anchor="w")
self.derived_spec_label_info = ttk.Label(self.derived_paths_frame, text="Spec File Found:")
self.derived_spec_label_val = ttk.Label(self.derived_paths_frame, text="N/A", foreground="grey", anchor="w")
self.derived_icon_label_info = ttk.Label(self.derived_paths_frame, text="Icon Found:")
self.derived_icon_label_val = ttk.Label(self.derived_paths_frame, text="N/A", foreground="grey", anchor="w")
# --- Section 2: Core Options (using Notebook for Tabs) ---
self.options_notebook = ttk.Notebook(parent_frame)
# --- Tab 1: Basic Options ---
self.basic_options_frame = ttk.Frame(self.options_notebook, padding="10")
self.options_notebook.add(self.basic_options_frame, text="Basic Options (from Spec/GUI)")
self.name_label = ttk.Label(self.basic_options_frame, text="App Name (from Spec/GUI):")
self.name_entry = ttk.Entry(self.basic_options_frame, textvariable=self.app_name)
self.icon_label = ttk.Label(self.basic_options_frame, text="Icon File (from Spec/GUI):")
self.icon_entry = ttk.Entry(self.basic_options_frame, textvariable=self.icon_path, state="readonly", width=70)
self.icon_button_browse = ttk.Button(self.basic_options_frame, text="Browse...", command=self._select_icon)
self.icon_button_clear = ttk.Button(self.basic_options_frame, text="Clear", command=self._clear_icon)
self.onefile_check = ttk.Checkbutton(self.basic_options_frame, text="Single File (One Executable)", variable=self.is_onefile)
self.windowed_check = ttk.Checkbutton(self.basic_options_frame, text="Windowed (No Console)", variable=self.is_windowed)
# --- Tab 2: Additional Files ---
self.data_files_frame = ttk.Frame(self.options_notebook, padding="10")
self.options_notebook.add(self.data_files_frame, text="Additional Files (from Spec/GUI)")
self.data_list_label = ttk.Label(self.data_files_frame, text="Files/Folders to Include (Source Relative to Project Directory -> Destination in bundle):")
self.data_list_scrollbar_y = ttk.Scrollbar(self.data_files_frame, orient=tk.VERTICAL)
self.data_list_scrollbar_x = ttk.Scrollbar(self.data_files_frame, orient=tk.HORIZONTAL)
self.data_listbox = tk.Listbox(
self.data_files_frame, selectmode=tk.SINGLE,
yscrollcommand=self.data_list_scrollbar_y.set, xscrollcommand=self.data_list_scrollbar_x.set,
height=6, width=80 )
self.data_list_scrollbar_y.config(command=self.data_listbox.yview)
self.data_list_scrollbar_x.config(command=self.data_listbox.xview)
self.data_buttons_frame = ttk.Frame(self.data_files_frame)
self.data_button_add_file = ttk.Button(self.data_buttons_frame, text="Add File...", command=self._add_data_file)
self.data_button_add_dir = ttk.Button(self.data_buttons_frame, text="Add Folder...", command=self._add_data_directory)
self.data_button_remove = ttk.Button(self.data_buttons_frame, text="Remove Selected", command=self._remove_selected_data)
# --- Section 3: Build Output Log ---
self.output_frame = ttk.LabelFrame(parent_frame, text="Build Log")
self.output_text = scrolledtext.ScrolledText(self.output_frame, wrap=tk.WORD, height=15, state="disabled")
# --- Section 4: Action Buttons ---
self.action_frame = ttk.Frame(parent_frame)
self.build_button = ttk.Button(self.action_frame, text="Build Executable", command=self._start_build, state="disabled")
# --- Layout Widgets ---
def _layout_widgets(self):
# Row configuration
row_idx = 0
self.main_frame.rowconfigure(row_idx, weight=0) # Project Dir
row_idx += 1
self.main_frame.rowconfigure(row_idx, weight=0) # Derived Paths
row_idx += 1
self.main_frame.rowconfigure(row_idx, weight=0) # Notebook row
row_idx += 1
self.main_frame.rowconfigure(row_idx, weight=1) # Output log expands
row_idx += 1
self.main_frame.rowconfigure(row_idx, weight=0) # Action buttons
self.main_frame.columnconfigure(0, weight=1)
# Layout Project Directory Selection
row_idx = 0
self.project_frame.grid(row=row_idx, column=0, padx=5, pady=(0, 5), sticky="ew")
self.project_frame.columnconfigure(1, weight=1)
self.project_dir_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.project_dir_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
self.project_dir_button_browse.grid(row=0, column=2, padx=5, pady=5)
# Layout Derived Paths Display
row_idx += 1
self.derived_paths_frame.grid(row=row_idx, column=0, padx=5, pady=5, sticky="ew")
self.derived_paths_frame.columnconfigure(1, weight=1)
self.derived_script_label_info.grid(row=0, column=0, padx=5, pady=2, sticky="w")
self.derived_script_label_val.grid(row=0, column=1, padx=5, pady=2, sticky="ew")
self.derived_spec_label_info.grid(row=1, column=0, padx=5, pady=2, sticky="w")
self.derived_spec_label_val.grid(row=1, column=1, padx=5, pady=2, sticky="ew")
self.derived_icon_label_info.grid(row=2, column=0, padx=5, pady=2, sticky="w")
self.derived_icon_label_val.grid(row=2, column=1, padx=5, pady=2, sticky="ew")
# Layout Options Notebook
row_idx += 1
self.options_notebook.grid(row=row_idx, column=0, padx=5, pady=5, sticky="ew")
# --- Layout Basic Options Tab ---
self.basic_options_frame.columnconfigure(1, weight=1)
self.name_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
self.name_entry.grid(row=0, column=1, columnspan=3, padx=5, pady=5, sticky="ew")
self.icon_label.grid(row=1, column=0, padx=5, pady=5, sticky="w")
self.icon_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
self.icon_button_browse.grid(row=1, column=2, padx=(0, 2), pady=5)
self.icon_button_clear.grid(row=1, column=3, padx=(2, 5), pady=5)
self.onefile_check.grid(row=2, column=0, columnspan=4, padx=5, pady=5, sticky="w")
self.windowed_check.grid(row=3, column=0, columnspan=4, padx=5, pady=5, sticky="w")
# --- Layout Data Files Tab ---
self.data_files_frame.columnconfigure(0, weight=1)
self.data_files_frame.rowconfigure(1, weight=1)
self.data_list_label.grid(row=0, column=0, columnspan=2, padx=5, pady=(0, 5), sticky="w")
self.data_listbox.grid(row=1, column=0, padx=(5, 0), pady=5, sticky="nsew")
self.data_list_scrollbar_y.grid(row=1, column=1, padx=(0, 5), pady=5, sticky="ns")
self.data_list_scrollbar_x.grid(row=2, column=0, padx=5, pady=(0, 5), sticky="ew")
self.data_buttons_frame.grid(row=1, column=2, rowspan=2, padx=5, pady=5, sticky="ns")
self.data_button_add_file.pack(pady=2, fill=tk.X)
self.data_button_add_dir.pack(pady=2, fill=tk.X)
self.data_button_remove.pack(pady=2, fill=tk.X)
# Layout Build Output Log
row_idx += 1
self.output_frame.grid(row=row_idx, column=0, padx=5, pady=5, sticky="nsew")
self.output_frame.rowconfigure(0, weight=1); self.output_frame.columnconfigure(0, weight=1)
self.output_text.grid(row=0, column=0, padx=5, pady=5, sticky="nsew")
# Layout Action Frame
row_idx += 1
self.action_frame.grid(row=row_idx, column=0, padx=5, pady=(5, 0), sticky="e")
self.build_button.pack(pady=5)
# --- Callback Methods ---
def _select_project_directory(self):
""" Handles project directory selection, derives paths, checks structure, loads spec. """
self._log_to_gui("="*20 + " PROJECT DIRECTORY SELECTION STARTED " + "="*20, level="INFO")
dir_path = filedialog.askdirectory(title="Select Main Project Directory")
if not dir_path:
self._log_to_gui("Directory selection cancelled.", level="INFO")
return
self.project_directory_path.set(dir_path)
self._log_to_gui(f"Selected project directory: {dir_path}", level="INFO")
project_root = pathlib.Path(dir_path)
self._project_root_name = project_root.name
project_name_lower = self._project_root_name.lower()
# Store derived paths as pathlib objects
self._derived_source_dir_path = project_root / project_name_lower
self._derived_main_script_path = self._derived_source_dir_path / "__main__.py"
self._derived_spec_path = project_root / f"{project_name_lower}.spec"
self._derived_icon_path = project_root / f"{project_name_lower}.ico"
# Log derived paths
self._log_to_gui(f"Derived project name: {self._project_root_name}", level="DEBUG")
self._log_to_gui(f" Expected source path: {self._derived_source_dir_path}", level="DEBUG")
self._log_to_gui(f" Expected main script: {self._derived_main_script_path}", level="DEBUG")
self._log_to_gui(f" Expected spec file: {self._derived_spec_path}", level="DEBUG")
self._log_to_gui(f" Expected icon file: {self._derived_icon_path}", level="DEBUG")
# Validate structure and update GUI labels
valid_structure = True
missing_files = []
# Check main script
if self._derived_main_script_path.is_file():
self.derived_script_label_val.config(text=str(self._derived_main_script_path), foreground="black")
else:
self.derived_script_label_val.config(text="NOT FOUND!", foreground="red")
missing_files.append(f"Main script ({self._derived_main_script_path.name})")
valid_structure = False
# Check spec file
if self._derived_spec_path.is_file():
self.derived_spec_label_val.config(text=str(self._derived_spec_path), foreground="black")
else:
self.derived_spec_label_val.config(text="NOT FOUND!", foreground="red")
missing_files.append(f"Spec file ({self._derived_spec_path.name})")
valid_structure = False # Spec file is considered essential
# Check icon file (optional)
if self._derived_icon_path.is_file():
self.derived_icon_label_val.config(text=str(self._derived_icon_path), foreground="black")
self.icon_path.set(str(self._derived_icon_path)) # Pre-fill GUI
else:
self.derived_icon_label_val.config(text="Not found (optional)", foreground="grey")
self.icon_path.set("") # Clear GUI field if not found
# Check source directory existence (important for writing _version.py)
if not self._derived_source_dir_path.is_dir():
self._log_to_gui(f"Warning: Expected source directory '{self._derived_source_dir_path.name}' does not exist.", level="WARNING")
# Consider if this should be treated as an error preventing build
# For now, we let the check in _start_build handle it if critical
# Reset options and try loading the spec file if found
self._reset_options_to_defaults(derive_name=True)
if self._derived_spec_path.is_file():
self._handle_existing_spec_file(str(self._derived_spec_path))
else:
self._log_to_gui(f"Spec file '{self._derived_spec_path.name}' not found. A new one will be generated if build proceeds.", level="WARNING")
# Set default app name if spec doesn't exist to provide one
self.app_name.set(self._project_root_name)
# Enable/Disable Build Button based on essential file existence
# We require main script and spec file for build to be possible.
if valid_structure:
self.build_button.config(state="normal")
self._log_to_gui("Project structure valid. Review options or click 'Build Executable'.", level="INFO")
else:
self.build_button.config(state="disabled")
error_msg = "Error: Invalid project structure or missing essential files:\n- " + "\n- ".join(missing_files)
self._log_to_gui(error_msg, level="ERROR")
messagebox.showerror("Invalid Project Structure", error_msg + "\n\nCannot proceed with build.")
def _handle_existing_spec_file(self, spec_path):
""" Checks for and parses an existing spec file using spec_parser. """
self._log_to_gui("-" * 15 + " Parsing Existing Spec File " + "-" * 15, level="INFO")
self._log_to_gui(f"Attempting to parse spec file: {spec_path}", level="INFO")
# Pass project root for context if needed by parser (for resolving relative paths)
parsed_options = spec_parser.parse_spec_file(spec_path, logger_func=self._log_to_gui)
if parsed_options is not None:
if parsed_options:
# Update GUI based on parsed options, providing project root for path resolution
self._update_gui_from_options(parsed_options, self.project_directory_path.get())
messagebox.showinfo("Spec File Loaded", f"Loaded options from:\n{spec_path}\nGUI fields updated.")
else:
messagebox.showwarning("Spec File Parsed - Options Not Recognized", f"Parsed spec file:\n{spec_path}\n\nNo common options recognized.\nBuild will use GUI settings and overwrite the spec.")
else:
# Parsing failed
messagebox.showerror("Spec File Parsing Error", f"Failed to parse spec file:\n{spec_path}\n\nCheck log. Proceeding will overwrite the spec.")
def _reset_options_to_defaults(self, derive_name=True):
""" Resets GUI option widgets and internal data lists to defaults. """
self._log_to_gui("Resetting options to default values.", level="INFO")
default_app_name = ""
# Derive name from project directory if flag is set and path is available
if derive_name and self._project_root_name:
default_app_name = self._project_root_name
# Set name: use derived, fallback to config default
self.app_name.set(default_app_name or config.DEFAULT_SPEC_OPTIONS['name'])
# Icon path is handled during directory selection/spec parsing/clearing
# self.icon_path.set(...) should not be reset here blindly
# Reset basic flags/dropdowns to config defaults
self.is_onefile.set(config.DEFAULT_SPEC_OPTIONS['onefile'])
self.is_windowed.set(config.DEFAULT_SPEC_OPTIONS['windowed'])
self.log_level.set(config.DEFAULT_SPEC_OPTIONS['log_level'])
# Reset data list and listbox
self.added_data_list.clear()
self.data_listbox.delete(0, tk.END)
self._log_to_gui("Additional files list reset.", level="DEBUG")
def _update_gui_from_options(self, options, project_root_dir):
""" Updates GUI widgets AND data lists based on parsed options from spec file. """
self._log_to_gui("Updating GUI from loaded/parsed spec options.", level="INFO")
# Update basic options from parsed spec data if available
if 'name' in options and options['name'] is not None: self.app_name.set(options['name'])
# Icon: Resolve path relative to project root if needed and check existence
if 'icon' in options and options['icon'] is not None:
icon_spec_path = pathlib.Path(options['icon'])
if not icon_spec_path.is_absolute():
abs_icon_path = pathlib.Path(project_root_dir) / icon_spec_path
self._log_to_gui(f" Resolved relative spec icon path: '{options['icon']}' -> '{abs_icon_path}'", level="DEBUG")
else:
abs_icon_path = icon_spec_path
# Only set GUI field if the resolved path actually exists
if abs_icon_path.exists():
self.icon_path.set(str(abs_icon_path))
else:
self._log_to_gui(f" Icon from spec '{abs_icon_path}' not found, GUI icon field unchanged.", level="WARNING")
# If 'icon' not in spec, self.icon_path retains its value (derived or cleared)
# Update flags if present and valid type in spec
if 'onefile' in options and isinstance(options.get('onefile'), bool): self.is_onefile.set(options['onefile'])
if 'windowed' in options and isinstance(options.get('windowed'), bool): self.is_windowed.set(options['windowed'])
# We don't currently parse log_level from spec, but could add it here if needed
# --- Update Added Data list/listbox from 'datas' in spec ---
self.added_data_list.clear()
self.data_listbox.delete(0, tk.END)
# Use .get() with default empty list for safety
parsed_datas = options.get('datas', [])
if parsed_datas:
self._log_to_gui(f"Found 'datas' in spec: {len(parsed_datas)} entries.", level="INFO")
project_root_path = pathlib.Path(project_root_dir)
for item in parsed_datas:
# Validate format: tuple/list of 2 strings
if isinstance(item, (tuple, list)) and len(item) == 2:
source_rel, destination = item
if isinstance(source_rel, str) and isinstance(destination, str):
# Store the relative source path (relative to spec file/project root)
self.added_data_list.append((source_rel, destination))
# Display the relative source path in the listbox
display_text = f"{source_rel} -> {destination}"
self.data_listbox.insert(tk.END, display_text)
# Log absolute path for verification (optional)
abs_source_check = project_root_path / source_rel
self._log_to_gui(f" Added data entry from spec: '{source_rel}' (abs: {abs_source_check}) -> '{destination}'", level="DEBUG")
else:
# Log if elements are not strings
self._log_to_gui(f" Ignored invalid 'datas' entry (non-strings): {item}", level="WARNING")
else:
# Log if format is incorrect
self._log_to_gui(f" Ignored invalid 'datas' entry (format not tuple/list of 2): {item}", level="WARNING")
self._log_to_gui("Additional files list populated from spec.", level="DEBUG")
else:
# Log if no 'datas' found or the list was empty/invalid
self._log_to_gui("No valid 'datas' entries found in spec.", level="DEBUG")
self._log_to_gui("GUI update from options completed.", level="INFO")
def _select_icon(self):
""" Handles icon file selection based on platform. Overrides derived/spec icon. """
project_dir = self.project_directory_path.get()
initial_dir = project_dir if project_dir else None # Start browsing from project dir
# Get platform-specific file types from config
if sys.platform == "win32": filetypes, default_ext = config.ICON_FILE_TYPES_WINDOWS, config.ICON_DEFAULT_EXT_WINDOWS
elif sys.platform == "darwin": filetypes, default_ext = config.ICON_FILE_TYPES_MACOS, config.ICON_DEFAULT_EXT_MACOS
else: filetypes, default_ext = config.ICON_FILE_TYPES_LINUX, config.ICON_DEFAULT_EXT_LINUX
# Show file dialog
file_path = filedialog.askopenfilename(title="Select Application Icon (Overrides)",
filetypes=filetypes,
defaultextension=default_ext,
initialdir=initial_dir)
if file_path:
# Update GUI if a file was selected
self.icon_path.set(file_path)
self._log_to_gui(f"Icon selected manually (overrides): {file_path}", level="INFO")
def _clear_icon(self):
""" Clears the icon selection in the GUI. """
self.icon_path.set("")
self._log_to_gui("Icon selection cleared.")
# Update the derived icon display label to reflect the change
if self._derived_icon_path and pathlib.Path(self._derived_icon_path).is_file():
# If derived exists, show it again
self.derived_icon_label_val.config(text=str(self._derived_icon_path), foreground="black")
else:
# Otherwise show 'not found'
self.derived_icon_label_val.config(text="Not found (optional)", foreground="grey")
def _add_data_file(self):
""" Adds a selected file to the 'add_data' list, storing source relative to project root. """
project_dir = self.project_directory_path.get()
if not project_dir:
messagebox.showerror("Error", "Select the project directory first."); return
# Show file dialog, starting in project directory
source_path = filedialog.askopenfilename(title="Select Source File to Add", initialdir=project_dir)
if not source_path:
self._log_to_gui("Add file cancelled.", level="DEBUG"); return
try:
# Resolve paths and calculate relative path
abs_source_path = pathlib.Path(source_path).resolve()
project_root_path = pathlib.Path(project_dir).resolve()
# Calculate source path relative to project root (for spec file)
relative_source_path = os.path.relpath(str(abs_source_path), str(project_root_path))
self._log_to_gui(f"Absolute source path: {abs_source_path}", level="DEBUG")
self._log_to_gui(f"Project root path: {project_root_path}", level="DEBUG")
self._log_to_gui(f"Calculated relative source path: {relative_source_path}", level="DEBUG")
except ValueError as e:
# Handle error if paths are on different drives (Windows)
messagebox.showerror("Path Error", f"Cannot calculate relative path.\nEnsure the file is on the same drive as the project directory or in a subfolder.\nError: {e}")
self._log_to_gui(f"Error calculating relative path between '{source_path}' and '{project_dir}': {e}", level="ERROR")
return
except Exception as e:
# Handle other unexpected path processing errors
messagebox.showerror("Error", f"Unexpected error processing path: {e}")
self._log_to_gui(f"Error processing path '{source_path}': {e}", level="ERROR")
return
# Ask for destination path within the bundle
dest_path = simpledialog.askstring("Destination in Bundle",
f"Relative destination path for:\n{abs_source_path.name}\n(e.g., '.', 'data', 'images/subdir')",
initialvalue=".")
if dest_path is None:
# User cancelled the destination dialog
self._log_to_gui("Add file cancelled (destination).", level="DEBUG"); return
# Add entry to internal list and GUI listbox
entry = (relative_source_path, dest_path)
self.added_data_list.append(entry)
# Display the relative source path in the listbox for clarity
display_text = f"{relative_source_path} -> {dest_path}"
self.data_listbox.insert(tk.END, display_text)
self._log_to_gui(f"Added data file: {display_text} (Source Relative to {project_dir})", level="INFO")
def _add_data_directory(self):
""" Adds a selected directory to the 'add_data' list, storing source relative to project root. """
project_dir = self.project_directory_path.get()
if not project_dir:
messagebox.showerror("Error", "Select the project directory first."); return
# Show directory dialog, starting in project directory
source_path = filedialog.askdirectory(title="Select Source Folder to Add", initialdir=project_dir)
if not source_path:
self._log_to_gui("Add folder cancelled.", level="DEBUG"); return
try:
# Resolve paths and calculate relative path
abs_source_path = pathlib.Path(source_path).resolve()
project_root_path = pathlib.Path(project_dir).resolve()
# Calculate source path relative to project root (for spec file)
relative_source_path = os.path.relpath(str(abs_source_path), str(project_root_path))
self._log_to_gui(f"Absolute source path: {abs_source_path}", level="DEBUG")
self._log_to_gui(f"Project root path: {project_root_path}", level="DEBUG")
self._log_to_gui(f"Calculated relative source path: {relative_source_path}", level="DEBUG")
except ValueError as e:
# Handle error if paths are on different drives (Windows)
messagebox.showerror("Path Error", f"Cannot calculate relative path.\nEnsure the folder is on the same drive as the project directory or in a subfolder.\nError: {e}")
self._log_to_gui(f"Error calculating relative path between '{source_path}' and '{project_dir}': {e}", level="ERROR")
return
except Exception as e:
# Handle other unexpected path processing errors
messagebox.showerror("Error", f"Unexpected error processing path: {e}")
self._log_to_gui(f"Error processing path '{source_path}': {e}", level="ERROR")
return
# Ask for destination path within the bundle, suggesting folder name as default
default_dest = abs_source_path.name # Default destination is the folder name itself
dest_path = simpledialog.askstring("Destination in Bundle",
f"Relative destination path for folder:\n{default_dest}\n(e.g., '{default_dest}', 'data/{default_dest}')",
initialvalue=default_dest)
if dest_path is None:
# User cancelled the destination dialog
self._log_to_gui("Add folder cancelled (destination).", level="DEBUG"); return
# Add entry to internal list and GUI listbox
entry = (relative_source_path, dest_path)
self.added_data_list.append(entry)
# Display the relative source path in the listbox for clarity
display_text = f"{relative_source_path} -> {dest_path}"
self.data_listbox.insert(tk.END, display_text)
self._log_to_gui(f"Added data folder: {display_text} (Source Relative to {project_dir})", level="INFO")
def _remove_selected_data(self):
""" Removes the selected item from the 'add_data' list and listbox. """
selected_indices = self.data_listbox.curselection()
if not selected_indices:
# Show warning if nothing is selected
messagebox.showwarning("No Selection", "Select an item to remove."); return
# Get index and text of the selected item
index = selected_indices[0]
item_text = self.data_listbox.get(index)
# Remove from listbox
self.data_listbox.delete(index)
# Remove from internal data list, checking index boundary for safety
if index < len(self.added_data_list):
removed_entry = self.added_data_list.pop(index)
self._log_to_gui(f"Removed data entry: {item_text}", level="INFO")
else:
# Log error if index is out of sync (should not happen normally)
self._log_to_gui(f"Error: Index {index} out of range for internal data list ({len(self.added_data_list)} entries). Listbox item: {item_text}", level="ERROR")
# --- Logging and Queue Handling ---
def _log_to_gui(self, message, level="INFO"):
""" Appends a formatted message to the output log area safely via queue if needed. """
formatted_message = f"[{level}] {str(message).strip()}\n"
# If called from main thread, update directly; otherwise, put in queue
if threading.current_thread() is threading.main_thread():
self._update_output_log_widget(formatted_message)
else:
# Use queue for thread-safe communication with the GUI thread
self.build_queue.put(("LOG", formatted_message))
def _update_output_log_widget(self, message):
""" Internal method to append text to the ScrolledText widget (GUI thread ONLY). """
try:
# Enable widget, insert text, scroll to end, disable widget
self.output_text.config(state="normal")
self.output_text.insert(tk.END, message)
self.output_text.see(tk.END) # Auto-scroll
self.output_text.config(state="disabled")
self.output_text.update_idletasks() # Ensure GUI updates immediately
except Exception as e:
# Print error to console if GUI logging fails
print(f"[ERROR] Error updating log widget: {e}\n{traceback.format_exc()}")
def _check_build_queue(self):
""" Periodically checks the build queue for messages from the builder thread. """
try:
# Process all available messages in the queue without blocking
while True:
# Get item non-blockingly
item = self.build_queue.get_nowait()
if isinstance(item, tuple) and len(item) == 2:
command, data = item
# Process different message types
if command == "LOG":
self._update_output_log_widget(data)
elif command == "BUILD_SUCCESS":
self._log_to_gui(data, level="SUCCESS")
messagebox.showinfo("Build Successful", data)
self._on_build_finished() # Update GUI state after success
elif command == "BUILD_ERROR":
self._log_to_gui(data, level="ERROR")
messagebox.showerror("Build Failed", data)
self._on_build_finished() # Update GUI state after error
elif command == "BUILD_FINISHED":
# Signal that the build process itself (success or fail) has ended
self._log_to_gui("Build process finished signal received.", level="DEBUG")
self._on_build_finished() # Ensure state is reset finally
else:
# Log unknown commands received via the queue
self._log_to_gui(f"Unknown command from queue: {command}", level="WARNING")
else:
# Log unexpected item format in the queue
self._log_to_gui(f"Unexpected item in queue: {item}", level="WARNING")
# Mark task as done for queue management
self.build_queue.task_done()
except queue.Empty:
# Queue is empty, nothing more to process right now
pass
except Exception as e:
# Log errors encountered while processing the queue itself
self._log_to_gui(f"Error processing build queue: {e}\n{traceback.format_exc()}", level="CRITICAL")
finally:
# Reschedule the check after a short delay (e.g., 100ms)
self.after(100, self._check_build_queue)
def _on_build_finished(self):
""" Actions to take when build completes (success or failure). Resets build state. """
# Check if a valid project is still selected and essential files exist
project_dir = self.project_directory_path.get()
can_build = (project_dir and
self._derived_main_script_path and pathlib.Path(self._derived_main_script_path).is_file() and
self._derived_spec_path) # We rely on spec existing or being generated
# Spec file existence check might be redundant if _start_build always saves it,
# but keep it for robustness in case saving fails silently or file is removed.
spec_file_exists = self._derived_spec_path and pathlib.Path(self._derived_spec_path).is_file()
# Re-enable build button only if conditions are met
self.build_button.config(state="normal" if can_build and spec_file_exists else "disabled")
# Clear the build thread reference
self.build_thread = None
self._log_to_gui("Build process finished. GUI controls (re-)enabled/disabled based on project state.", level="INFO")
def _generate_spec_content(self):
""" Generates the .spec file content based on GUI options, using derived paths. """
project_dir = self.project_directory_path.get()
# Validate essential inputs
if not project_dir:
messagebox.showerror("Error", "Project directory not selected."); return None
if not self._derived_main_script_path or not pathlib.Path(self._derived_main_script_path).is_file():
messagebox.showerror("Error", f"Main script '{self._derived_main_script_path}' not found or invalid."); return None
self._log_to_gui("Generating .spec content from GUI options...", level="INFO")
project_root_path = pathlib.Path(project_dir).resolve()
# Calculate script path relative to project root
try:
script_rel_path = os.path.relpath(str(pathlib.Path(self._derived_main_script_path).resolve()), str(project_root_path))
except Exception as e:
self._log_to_gui(f"Cannot make script path relative '{self._derived_main_script_path}': {e}", level="ERROR")
messagebox.showerror("Path Error", f"Cannot calculate relative path for the main script:\n{e}")
return None
# Calculate source directory path relative to project root, fallback to '.'
try:
source_dir_abs = pathlib.Path(self._derived_source_dir_path).resolve()
if not source_dir_abs.is_dir():
self._log_to_gui(f"Source directory '{source_dir_abs}' does not exist, using '.' for pathex.", level="WARNING")
source_dir_rel_path = "."
else:
source_dir_rel_path = os.path.relpath(str(source_dir_abs), str(project_root_path))
except Exception as e:
self._log_to_gui(f"Cannot make source dir path relative '{self._derived_source_dir_path}': {e}. Using '.' for pathex.", level="WARNING")
source_dir_rel_path = "."
# Get app name from GUI or derive from project name
app_name_val = self.app_name.get() or self._project_root_name
# Get icon path from GUI and make it relative if valid
icon_gui_path = self.icon_path.get()
icon_rel_path = None
if icon_gui_path and pathlib.Path(icon_gui_path).is_file():
try:
icon_rel_path = os.path.relpath(str(pathlib.Path(icon_gui_path).resolve()), str(project_root_path))
except Exception as e:
self._log_to_gui(f"Cannot make icon path relative '{icon_gui_path}': {e}. Icon will not be included in generated spec.", level="WARNING")
# icon_rel_path remains None
# Helper to escape backslashes for Python strings in the spec file
def escape_path(p): return p.replace("\\", "\\\\") if p else ""
# --- Analysis Section ---
# Use relative paths within the spec file context
analysis_scripts = f"['{escape_path(script_rel_path)}']"
# Pathex should include the source directory relative to the spec file
analysis_pathex = f"['{escape_path(source_dir_rel_path)}']"
# 'datas' uses relative paths already stored in self.added_data_list
formatted_datas = "[" + ",\n ".join([f"('{escape_path(src)}', '{escape_path(dst)}')" for src, dst in self.added_data_list]) + "]"
self._log_to_gui(f"Formatted {len(self.added_data_list)} 'datas' entries for spec (sources relative to project).", level="DEBUG")
# Placeholders for other Analysis options
formatted_hiddenimports = "[]"; formatted_binaries = "[]"
# Construct the Analysis block string
analysis_str = f"""a = Analysis(scripts={analysis_scripts},
pathex={analysis_pathex},
binaries={formatted_binaries},
datas={formatted_datas},
hiddenimports={formatted_hiddenimports},
hookspath=[],
hooksconfig={{}},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=None,
noarchive=False)"""
# --- PYZ Section ---
pyz_str = "pyz = PYZ(a.pure, a.zipped_data, cipher=None)"
# --- EXE Section ---
# Construct the EXE block string with options from GUI/defaults
exe_str = f"""exe = EXE(pyz,
a.scripts,
[], # Binaries/Datas usually handled by Analysis/COLLECT
exclude_binaries=True, # Let COLLECT handle binaries in one-dir
name='{app_name_val}',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx={config.DEFAULT_SPEC_OPTIONS['use_upx']}, # Use UPX based on config
runtime_tmpdir=None,
console={not self.is_windowed.get()}, # Set console based on GUI checkbox
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None"""
# Add icon option only if a valid relative path was determined
if icon_rel_path:
exe_str += f",\n icon='{escape_path(icon_rel_path)}'"
exe_str += ")" # Close the EXE parenthesis
# --- COLLECT Section (for one-dir builds) ---
collect_str = ""
# Include COLLECT only if 'onefile' checkbox is NOT checked
if not self.is_onefile.get():
collect_str = f"""coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx={config.DEFAULT_SPEC_OPTIONS['use_upx']}, # Match UPX setting
upx_exclude=[],
name='{app_name_val}')""" # Use app name for the output folder
# --- Combine Spec Content ---
spec_header = "# -*- mode: python ; coding: utf-8 -*-\n\nblock_cipher = None\n"
# Add imports potentially needed by generated spec sections (e.g., os for COLLECT)
spec_imports = ""
if collect_str:
spec_imports = "import os\n" # COLLECT sometimes needs os
# Assemble the final spec content string
spec_content = f"{spec_header}\n{spec_imports}{analysis_str}\n\n{pyz_str}\n\n{exe_str}\n"
if collect_str:
spec_content += f"\n{collect_str}\n" # Add COLLECT section if applicable
self._log_to_gui(".spec file content generated.", level="DEBUG")
# Return the generated content with a trailing newline
return spec_content.strip() + "\n"
def _save_spec_file(self, content):
""" Saves the generated content to the derived .spec file path. """
# Validate that the derived spec path is set
if not self._derived_spec_path:
messagebox.showerror("Error", "Cannot save spec: derived path not set or invalid."); return False
# Convert pathlib.Path to string for file operations
spec_path_to_save = str(self._derived_spec_path)
self._log_to_gui(f"Attempting to save spec file to: {spec_path_to_save}", level="INFO")
try:
# Ensure parent directory exists
os.makedirs(os.path.dirname(spec_path_to_save), exist_ok=True)
# Write content to the spec file
with open(spec_path_to_save, 'w', encoding='utf-8') as f:
f.write(content)
# Log success and update GUI label
self._log_to_gui(f"Spec file saved successfully: {spec_path_to_save}", level="INFO")
self.derived_spec_label_val.config(text=spec_path_to_save, foreground="black")
return True # Indicate success
except Exception as e:
# Log and show error if saving fails
error_msg = f"Failed to save spec file '{spec_path_to_save}': {e}\n{traceback.format_exc()}"
self._log_to_gui(error_msg, level="ERROR"); messagebox.showerror("File Save Error", error_msg); return False
# --- Build Execution ---
def _start_build(self):
""" Validates project, generates target _version.py, generates/saves spec, starts build thread. """
self._log_to_gui("="*20 + " BUILD PROCESS STARTED " + "="*20, level="INFO")
project_dir = self.project_directory_path.get()
# --- Step 1: Validate Project Directory and Derived Paths ---
if not project_dir or not os.path.isdir(project_dir):
messagebox.showerror("Error", "Select a valid project directory."); self._log_to_gui("Build cancelled: invalid project directory.", level="ERROR"); return
# Ensure all necessary derived paths were calculated successfully
if not all([self._derived_main_script_path, self._derived_spec_path, self._derived_source_dir_path]):
messagebox.showerror("Error", "Internal Error: Derived paths not set. Reselect the project directory."); self._log_to_gui("Build cancelled: internal path error.", level="ERROR"); return
# --- Step 2: Validate Existence of Essential Files/Dirs ---
if not os.path.exists(self._derived_main_script_path):
messagebox.showerror("Error", f"Main script '{self._derived_main_script_path}' not found. Cannot proceed."); self._log_to_gui("Build cancelled: main script missing.", level="ERROR"); return
# Crucially check source directory existence before trying to write _version.py
if not self._derived_source_dir_path.is_dir(): # Check if the Path object points to a directory
messagebox.showerror("Error", f"Source directory '{self._derived_source_dir_path}' not found. Cannot generate version file."); self._log_to_gui("Build cancelled: source directory missing.", level="ERROR"); return
# --- Step 3: Generate _version.py for the Target Project ---
# Define the target path for the _version.py file
target_version_file = self._derived_source_dir_path / "_version.py"
# Call the generation method and check for critical failure
if not self._generate_target_version_file(project_dir, target_version_file):
# Error message already shown by the generation method if writing failed
self._log_to_gui("Build cancelled: Failed to generate target version file.", level="ERROR")
return # Stop the build
# --- Step 4: Generate and Save Spec File (Overwrites existing) ---
self._log_to_gui("Generating spec content from current GUI options...", level="INFO")
spec_content = self._generate_spec_content()
# Check if spec generation was successful
if spec_content is None:
self._log_to_gui("Build cancelled: spec generation failed.", level="ERROR"); return
# Ensure the spec path is a valid string or Path object
if not isinstance(self._derived_spec_path, (str, pathlib.Path)):
messagebox.showerror("Error", "Internal Error: Invalid spec path type."); self._log_to_gui("Build cancelled: internal spec path type error.", level="ERROR"); return
# Convert to string for saving function
spec_path_to_save = str(self._derived_spec_path)
self._log_to_gui(f"Saving spec file to '{spec_path_to_save}' (will overwrite if exists)...", level="INFO")
# Attempt to save the spec file and stop if it fails
if not self._save_spec_file(spec_content):
self._log_to_gui("Build cancelled: spec save failed.", level="ERROR"); return
# --- Step 5: Prepare PyInstaller Command ---
self._log_to_gui("Preparing PyInstaller execution...", level="INFO")
self.build_button.config(state="disabled") # Disable button during build
working_dir = project_dir # PyInstaller should run from the project root
# Define output directories relative to the working directory
dist_path_abs = os.path.join(working_dir, config.DEFAULT_SPEC_OPTIONS['output_dir_name'])
work_path_abs = os.path.join(working_dir, config.DEFAULT_SPEC_OPTIONS['work_dir_name'])
# Find the PyInstaller executable
pyinstaller_cmd_path = shutil.which("pyinstaller")
if not pyinstaller_cmd_path:
# Show error if PyInstaller is not found
error_msg = ("'pyinstaller' command not found in PATH.\n"
"Ensure it is installed and in your system's PATH.")
self._log_to_gui(error_msg, level="CRITICAL"); messagebox.showerror("PyInstaller Error", error_msg)
self._on_build_finished() # Reset GUI state
return
self._log_to_gui(f"Found PyInstaller executable at: {pyinstaller_cmd_path}", level="DEBUG")
# Construct the PyInstaller command list
command = [
pyinstaller_cmd_path,
spec_path_to_save, # Pass the path to the generated/saved spec file
"--distpath", dist_path_abs, # Specify output directory
"--workpath", work_path_abs, # Specify build directory
"--log-level", self.log_level.get() # Set log level from GUI
]
# Add common flags based on config/defaults
if config.DEFAULT_SPEC_OPTIONS['clean_build']:
command.append("--clean") # Force clean build
if not config.DEFAULT_SPEC_OPTIONS['confirm_overwrite']:
command.append("--noconfirm") # Don't ask for confirmation to overwrite output
# --- Step 6: Prepare Environment (Clean Tcl/Tk Vars) ---
build_env = os.environ.copy()
removed_vars = []
# Remove potentially conflicting Tcl/Tk variables inherited from the GUI environment
if 'TCL_LIBRARY' in build_env:
del build_env['TCL_LIBRARY']; removed_vars.append('TCL_LIBRARY')
if 'TK_LIBRARY' in build_env:
del build_env['TK_LIBRARY']; removed_vars.append('TK_LIBRARY')
# Log if any variables were removed
if removed_vars:
self._log_to_gui(f"Removed environment variables {removed_vars} for external PyInstaller.", level="DEBUG")
else:
self._log_to_gui("No inherited Tcl/Tk environment variables found to remove.", level="DEBUG")
# Log the final command and working directory
quoted_command = [f'"{arg}"' if ' ' in arg else arg for arg in command]
self._log_to_gui(f"Execution Command: {' '.join(quoted_command)}", level="DEBUG")
self._log_to_gui(f"Working Directory: {working_dir}", level="DEBUG")
# --- Step 7: Start Build Thread ---
self.build_thread = threading.Thread(
target=builder.run_build_in_thread,
args=(
command, # The command list
working_dir, # The directory to run in
self.build_queue, # Queue for log messages/status
self._log_to_gui, # Logging function
config.DEFAULT_SPEC_OPTIONS['output_dir_name'], # Expected output folder name
build_env # The cleaned environment dictionary
),
daemon=True # Allow application to exit even if thread is running
)
self.build_thread.start()
self._log_to_gui("Build thread started.", level="INFO")
# --- End of PyInstallerGUI class ---