1128 lines
62 KiB
Python
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 --- |