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