diff --git a/.gitignore b/.gitignore index 4a95dc3..e60690a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .svn _dist/ _build/ -__pycache__/ \ No newline at end of file +__pycache__/ +_version.py \ No newline at end of file diff --git a/pyinstallerguiwrapper.spec b/pyinstallerguiwrapper.spec index 3fefc9d..e1c5005 100644 --- a/pyinstallerguiwrapper.spec +++ b/pyinstallerguiwrapper.spec @@ -1,63 +1,45 @@ # -*- mode: python ; coding: utf-8 -*- -# This spec file is for building the PyInstaller GUI Wrapper tool itself. - block_cipher = None -a = Analysis( - # MODIFIED: Point to the new entry point within the subpackage - scripts=['pyinstallerguiwrapper/__main__.py'], - # MODIFIED: Include the root directory '.' so PyInstaller finds the subpackage 'pyinstallerguiwrapper' - pathex=['.'], - binaries=[], - datas=[], # No data files needed for the tool itself currently - hiddenimports=[], # Add any modules PyInstaller might miss (e.g., sometimes needed for pkg_resources) - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False -) -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) +import os +a = Analysis(scripts=['pyinstallerguiwrapper\\__main__.py'], + pathex=['pyinstallerguiwrapper'], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=None, + noarchive=False) -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - # MODIFIED: Set the application name - name='PyInstallerGUIWrapper', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, # Use UPX if available (optional) - runtime_tmpdir=None, - # MODIFIED: Set console=False for a GUI application - console=False, - disable_windowed_traceback=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - # MODIFIED: Add icon if pyinstallerguiwrapper.ico exists in the root - icon='pyinstallerguiwrapper.ico' # Assumes the icon file is in the same directory as the spec file -) +pyz = PYZ(a.pure, a.zipped_data, cipher=None) -# Use COLLECT for one-dir builds (recommended for GUI stability) -coll = COLLECT( - exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=True, # Match UPX setting - upx_exclude=[], - # MODIFIED: Set the output directory name - name='PyInstallerGUIWrapper' # Name of the folder in _dist/ -) +exe = EXE(pyz, + a.scripts, + [], # Binaries/Datas usually handled by Analysis/COLLECT + exclude_binaries=True, # Let COLLECT handle binaries in one-dir + name='PyInstallerGUIWrapper', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, # Use UPX based on config + runtime_tmpdir=None, + console=False, # Set console based on GUI checkbox + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None) -# If you wanted a one-file build instead (can sometimes be less stable for Tkinter): -# Remove the 'coll = COLLECT(...)' block above -# And potentially adjust EXE options (though Analysis usually handles datas/binaries correctly now) \ No newline at end of file +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, # Match UPX setting + upx_exclude=[], + name='PyInstallerGUIWrapper') diff --git a/pyinstallerguiwrapper/__main__.py b/pyinstallerguiwrapper/__main__.py index 40da0b1..a45e937 100644 --- a/pyinstallerguiwrapper/__main__.py +++ b/pyinstallerguiwrapper/__main__.py @@ -13,7 +13,7 @@ import traceback # Import traceback for better error reporting # --- Import the necessary modules --- try: # MODIFIED: Use relative import to get the GUI class from within the same package - from .gui import PyInstallerGUI + from pyinstallerguiwrapper.gui import PyInstallerGUI except ImportError as e: # Provide more context in case of import errors package_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/pyinstallerguiwrapper/gui.py b/pyinstallerguiwrapper/gui.py index e0a1ee1..1bb0e8a 100644 --- a/pyinstallerguiwrapper/gui.py +++ b/pyinstallerguiwrapper/gui.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""gui.py - Main GUI module for the PyInstaller Wrapper.""" +""" gui.py - Main GUI module for the PyInstaller Wrapper. """ import tkinter as tk from tkinter import ttk @@ -14,40 +14,60 @@ 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) -# MODIFIED: Imports changed to relative imports -# Import other modules from the package/directory -from . import config # Changed from 'import config' -from . import spec_parser # Changed from 'import spec_parser' -from . import builder # Changed from 'import builder' +# --- 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.""" - + """ Main application window for the PyInstaller GUI tool. """ def __init__(self): - """Initialize the main application window, variables, and widgets.""" + """ Initialize the main application window, variables, and widgets. """ super().__init__() - self.title("PyInstaller GUI Wrapper") - # ... (rest of the __init__ method remains the same as the previous version) ... - # --- Project Path --- - self.project_directory_path = tk.StringVar() + # Use the Wrapper's version in the title + self.title(f"PyInstaller GUI Wrapper - {WRAPPER_APP_VERSION_STRING}") - # --- Derived Paths (Internal state) --- + # --- 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 = "" - self._project_root_name = "" + 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() - self.app_name = tk.StringVar() - 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"]) + 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 --- + # --- Data Structures for Complex Options --- self.added_data_list = [] # --- Build Process State --- @@ -63,157 +83,290 @@ class PyInstallerGUI(tk.Tk): 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( - "Application initialized. Select the project directory.", level="INFO" - ) + 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})" + - # ... (ALL OTHER METHODS _create_widgets, _layout_widgets, callbacks, etc. remain EXACTLY THE SAME as the fully translated version from the previous step) ... # --- Widget Creation --- def _create_widgets(self, parent_frame): - # ... (code from previous step) ... # --- 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 - ) - # ... (rest of widget creation) ... - # --- Section 1.5: Derived Paths Display (Optional but helpful) --- - 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" - ) + 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, - ) + 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.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_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, - ) + 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" - ) + 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", - ) + self.build_button = ttk.Button(self.action_frame, text="Build Executable", command=self._start_build, state="disabled") # --- Layout Widgets --- def _layout_widgets(self): - # ... (code from previous step) ... + # Row configuration row_idx = 0 - self.main_frame.rowconfigure(row_idx, weight=0) # Project Dir + self.main_frame.rowconfigure(row_idx, weight=0) # Project Dir row_idx += 1 - self.main_frame.rowconfigure(row_idx, weight=0) # Derived Paths + self.main_frame.rowconfigure(row_idx, weight=0) # Derived Paths row_idx += 1 - self.main_frame.rowconfigure(row_idx, weight=0) # Notebook row + self.main_frame.rowconfigure(row_idx, weight=0) # Notebook row row_idx += 1 - self.main_frame.rowconfigure(row_idx, weight=1) # Output log expands + 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.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") @@ -223,9 +376,7 @@ class PyInstallerGUI(tk.Tk): 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.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") @@ -244,36 +395,23 @@ class PyInstallerGUI(tk.Tk): 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" - ) + 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_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_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_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 @@ -282,555 +420,499 @@ class PyInstallerGUI(tk.Tk): # --- Callback Methods --- def _select_project_directory(self): - # ... (code from previous step) ... - self._log_to_gui( - "=" * 20 + " PROJECT DIRECTORY SELECTION STARTED " + "=" * 20, level="INFO" - ) + """ 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" - 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" - ) + + # 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" - ) + 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" - ) + 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 + 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)) + 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("") + 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", - ) + 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", - ) + 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", - ) + 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 files:\n- " - + "\n- ".join(missing_files) - ) + 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.", - ) + messagebox.showerror("Invalid Project Structure", error_msg + "\n\nCannot proceed with build.") + def _handle_existing_spec_file(self, spec_path): - # ... (code from previous step) ... - self._log_to_gui( - "-" * 15 + " Parsing Existing Spec File " + "-" * 15, level="INFO" - ) + """ 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") - parsed_options = spec_parser.parse_spec_file( - spec_path, logger_func=self._log_to_gui - ) + # 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: - 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.", - ) + # 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.", - ) + 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: - messagebox.showerror( - "Spec File Parsing Error", - f"Failed to parse spec file:\n{spec_path}\n\nCheck log. Proceeding will overwrite the spec.", - ) + # 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): - # ... (code from previous step) ... + """ 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 - self.app_name.set(default_app_name or config.DEFAULT_SPEC_OPTIONS["name"]) - 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"]) + + # 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): - # ... (code from previous step) ... + """ 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") - if "name" in options and options["name"] is not None: - self.app_name.set(options["name"]) - if "icon" in options and options["icon"] is not None: - icon_spec_path = pathlib.Path(options["icon"]) + + # 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", - ) + 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)) + 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 "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"]) - if ( - "log_level" in options - and options.get("log_level") in config.PYINSTALLER_LOG_LEVELS - ): - self.log_level.set(options["log_level"]) + 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) - parsed_datas = options.get("datas", []) + # 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" - ) + 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", - ) + self._log_to_gui(f" Added data entry from spec: '{source_rel}' (abs: {abs_source_check}) -> '{destination}'", level="DEBUG") else: - self._log_to_gui( - f" Ignored invalid 'datas' entry (non-strings): {item}", - level="WARNING", - ) + # Log if elements are not strings + self._log_to_gui(f" Ignored invalid 'datas' entry (non-strings): {item}", level="WARNING") else: - 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" - ) + # 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): - # ... (code from previous step) ... + """ 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 - 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, - ) - file_path = filedialog.askopenfilename( - title="Select Application Icon (Overrides)", - filetypes=filetypes, - defaultextension=default_ext, - initialdir=initial_dir, - ) + 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" - ) + self._log_to_gui(f"Icon selected manually (overrides): {file_path}", level="INFO") + def _clear_icon(self): - # ... (code from previous step) ... + """ 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(): - self.derived_icon_label_val.config( - text=str(self._derived_icon_path), foreground="black" - ) + # If derived exists, show it again + self.derived_icon_label_val.config(text=str(self._derived_icon_path), foreground="black") else: - self.derived_icon_label_val.config( - text="Not found (optional)", foreground="grey" - ) + # Otherwise show 'not found' + self.derived_icon_label_val.config(text="Not found (optional)", foreground="grey") + def _add_data_file(self): - # ... (code from previous step) ... + """ 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 - source_path = filedialog.askopenfilename( - title="Select Source File to Add", initialdir=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 + 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() - relative_source_path = os.path.relpath( - str(abs_source_path), str(project_root_path) - ) + # 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", - ) + self._log_to_gui(f"Calculated relative source path: {relative_source_path}", level="DEBUG") + except ValueError as e: - 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 + # 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" - ) + self._log_to_gui(f"Error processing path '{source_path}': {e}", level="ERROR") return - dest_path = simpledialog.askstring( - "Destination in Bundle", - f"Relative destination path for:\n{abs_source_path.name}\n(e.g., '.', 'data', 'images/subdir')", - initialvalue=".", - ) + + # 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: - self._log_to_gui("Add file cancelled (destination).", level="DEBUG") - return + # 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", - ) + self._log_to_gui(f"Added data file: {display_text} (Source Relative to {project_dir})", level="INFO") + def _add_data_directory(self): - # ... (code from previous step) ... + """ 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 - source_path = filedialog.askdirectory( - title="Select Source Folder to Add", initialdir=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 + 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() - relative_source_path = os.path.relpath( - str(abs_source_path), str(project_root_path) - ) + # 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", - ) + self._log_to_gui(f"Calculated relative source path: {relative_source_path}", level="DEBUG") + except ValueError as e: - 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 + # 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" - ) + self._log_to_gui(f"Error processing path '{source_path}': {e}", level="ERROR") return - default_dest = abs_source_path.name - 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, - ) + + # 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: - self._log_to_gui("Add folder cancelled (destination).", level="DEBUG") - return + # 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", - ) + self._log_to_gui(f"Added data folder: {display_text} (Source Relative to {project_dir})", level="INFO") + def _remove_selected_data(self): - # ... (code from previous step) ... + """ Removes the selected item from the 'add_data' list and listbox. """ selected_indices = self.data_listbox.curselection() if not selected_indices: - messagebox.showwarning("No Selection", "Select an item to remove.") - return + # 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: - 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", - ) + # 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"): - # ... (code from previous step) ... + """ 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): - # ... (code from previous step) ... - try: - self.output_text.config(state="normal") - self.output_text.insert(tk.END, message) - self.output_text.see(tk.END) - self.output_text.config(state="disabled") - self.output_text.update_idletasks() - except Exception as e: - print(f"[ERROR] Error updating log widget: {e}\n{traceback.format_exc()}") + """ 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): - # ... (code from previous step) ... + """ 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() + 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() + self._on_build_finished() # Update GUI state after error elif command == "BUILD_FINISHED": - self._log_to_gui( - "Build process finished signal received.", level="DEBUG" - ) - self._on_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: - self._log_to_gui( - f"Unknown command from queue: {command}", level="WARNING" - ) + # Log unknown commands received via the queue + self._log_to_gui(f"Unknown command from queue: {command}", level="WARNING") else: - self._log_to_gui( - f"Unexpected item in queue: {item}", level="WARNING" - ) + # 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: - self._log_to_gui( - f"Error processing build queue: {e}\n{traceback.format_exc()}", - level="CRITICAL", - ) + # 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): - # ... (code from previous step, including spec_file_exists check) ... - 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 - ) - spec_file_exists = ( - self._derived_spec_path and pathlib.Path(self._derived_spec_path).is_file() - ) - self.build_button.config( - state="normal" if can_build and spec_file_exists else "disabled" - ) - self.build_thread = None - self._log_to_gui( - "Build process finished. GUI controls (re-)enabled/disabled based on project state.", - level="INFO", - ) - # --- Spec File Generation --- + 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): - # ... (code from previous step, using corrected EXE/COLLECT structure) ... + """ 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 + 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), - ) + 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}", - ) + 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 = "." + 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) - ) + 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", - ) + 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 = None + 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 - def escape_path(p): - return p.replace("\\", "\\\\") if p else "" + # 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)}']" - analysis_pathex = f"['{escape_path(source_dir_rel_path)}']" # Path relative to project root/spec - 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", - ) - formatted_hiddenimports = "[]" - formatted_binaries = "[]" + # 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}, @@ -844,188 +926,203 @@ class PyInstallerGUI(tk.Tk): 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, - [], - exclude_binaries=True, + [], # 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']}, + upx={config.DEFAULT_SPEC_OPTIONS['use_upx']}, # Use UPX based on config runtime_tmpdir=None, - console={not self.is_windowed.get()}, + 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 += ")" + 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, + collect_str = f"""coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, strip=False, - upx={config.DEFAULT_SPEC_OPTIONS['use_upx']}, + upx={config.DEFAULT_SPEC_OPTIONS['use_upx']}, # Match UPX setting upx_exclude=[], - name='{app_name_val}')""" + 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" - spec_content = ( - f"{spec_header}\n{spec_imports}{analysis_str}\n\n{pyz_str}\n\n{exe_str}\n" - ) + 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" + 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" - # --- Save Spec File --- + def _save_spec_file(self, content): - # ... (code from previous step) ... + """ 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 + 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" - ) + 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) - with open(spec_path_to_save, "w", encoding="utf-8") as f: + # Write content to the spec file + with open(spec_path_to_save, 'w', encoding='utf-8') as f: f.write(content) - 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 + # 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 + self._log_to_gui(error_msg, level="ERROR"); messagebox.showerror("File Save Error", error_msg); return False + # --- Build Execution --- def _start_build(self): - # ... (code from previous step) ... - self._log_to_gui("=" * 20 + " BUILD PROCESS STARTED " + "=" * 20, level="INFO") + """ 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 - if not self._derived_main_script_path or 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 - if not self._derived_spec_path: - messagebox.showerror( - "Error", "Internal Error: derived spec path not available." - ) - self._log_to_gui( - "Build cancelled: derived spec path not set.", level="ERROR" - ) - return - self._log_to_gui( - "Generating spec content from current GUI options...", level="INFO" - ) + 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 - self._log_to_gui( - f"Saving spec file to '{self._derived_spec_path}' (will overwrite if exists)...", - level="INFO", - ) + 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 + 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") - working_dir = project_dir - 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"] - ) + 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: - error_msg = "'pyinstaller' command not found in PATH.\nEnsure 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() + # 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" - ) + + self._log_to_gui(f"Found PyInstaller executable at: {pyinstaller_cmd_path}", level="DEBUG") + + # Construct the PyInstaller command list command = [ pyinstaller_cmd_path, - str(self._derived_spec_path), - "--distpath", - dist_path_abs, - "--workpath", - work_path_abs, - "--log-level", - self.log_level.get(), + 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 ] - if config.DEFAULT_SPEC_OPTIONS["clean_build"]: - command.append("--clean") - if not config.DEFAULT_SPEC_OPTIONS["confirm_overwrite"]: - command.append("--noconfirm") + # 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 = [] - 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") + # 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", - ) + 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", - ) - 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("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, - working_dir, - self.build_queue, - self._log_to_gui, - config.DEFAULT_SPEC_OPTIONS["output_dir_name"], - build_env, + 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, + 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 --- +# --- End of PyInstallerGUI class --- \ No newline at end of file