diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..60f7044 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Module", + "type": "debugpy", + "request": "launch", + "module": "pyinstallerguiwrapper" + } + ] +} \ No newline at end of file diff --git a/CreateExecFromPy.spec b/CreateExecFromPy.spec deleted file mode 100644 index 1ce2bea..0000000 --- a/CreateExecFromPy.spec +++ /dev/null @@ -1,16 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -block_cipher = None - -a = Analysis(scripts=['C:\\src\\____GitProjects\\CreateExecFromPy\\CreateExecFromPy.py'], pathex=['C:\\src\\____GitProjects\\CreateExecFromPy'], binaries=[], datas=[], hiddenimports=[], hookspath=[], runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=None, noarchive=False,) - -pyz = PYZ(a.pure, a.zipped_data, cipher=None) - -exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='CreateExecFromPy', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=False,) diff --git a/builder.py b/builder.py deleted file mode 100644 index 199e525..0000000 --- a/builder.py +++ /dev/null @@ -1,115 +0,0 @@ -# -*- coding: utf-8 -*- -""" builder.py - Handles the execution of the PyInstaller build process in a separate thread. """ - -import subprocess -import threading -import queue -import os -import traceback # For logging exceptions - -def run_build_in_thread(command, working_dir, output_queue, logger_func, output_dir_name, environment=None): - """ - Executes the PyInstaller command using subprocess.Popen in the background. - - Designed to be run in a separate thread. Captures stdout/stderr and sends - messages (LOG, BUILD_SUCCESS, BUILD_ERROR, BUILD_FINISHED) back to the - main GUI thread via the provided queue. - - Args: - command (list): The command list to execute (e.g., ['pyinstaller', ...]). - working_dir (str): The directory where the command should be executed. - output_queue (queue.Queue): The queue to send status messages back. - logger_func (callable): Function for logging (takes message, level). - output_dir_name (str): Expected output directory name (e.g., '_dist'). - environment (dict, optional): Environment variables for the subprocess. - Defaults to None (inherit/use system default). - """ - build_process = None # Define variable outside try block for broader scope if needed - try: - logger_func("Build thread starting execution...", level="INFO") - - # Log the environment being used if it's custom - env_log_msg = f"Using {'custom' if environment else 'inherited'} environment." - if environment: - # Optionally log specific variables for debugging (be careful with sensitive data) - # env_details = {k: v for k, v in environment.items() if 'TCL' in k or 'TK' in k or 'PATH' in k} - # env_log_msg += f" Details (partial): {env_details}" - pass # Avoid logging full env by default - logger_func(f"PyInstaller process starting. {env_log_msg}", level="INFO") - - - # Start the PyInstaller process - build_process = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, # Redirect stderr to stdout - cwd=working_dir, # Set working directory - text=True, # Decode output as text - encoding='utf-8', # Specify encoding - errors='replace', # Handle decoding errors gracefully - bufsize=1, # Line-buffered output - env=environment # Pass the specific environment - # Note: creationflags might be needed on Windows to hide console, - # e.g., creationflags=subprocess.CREATE_NO_WINDOW - ) - logger_func(f"PyInstaller process launched (PID: {build_process.pid}).", level="INFO") - - # --- Read output line by line in real-time --- - while True: - try: - line = build_process.stdout.readline() - if not line: - # Check if process ended before breaking the loop - if build_process.poll() is not None: - logger_func("End of PyInstaller output stream.", level="DEBUG") - break - else: - # Process might still be running, briefly wait or continue loop - pass # readline should block, so usually no need to sleep - else: - # Send the captured line to the GUI log via the queue - output_queue.put(("LOG", line)) - except Exception as read_err: - # Log errors during stream reading - logger_func(f"Error reading PyInstaller output stream: {read_err}\n{traceback.format_exc()}", level="ERROR") - # Decide whether to break or continue based on the error - break # Safer to break if reading fails - - # --- Wait for process completion and get exit code --- - return_code = build_process.wait() - logger_func(f"PyInstaller process finished with exit code: {return_code}", level="INFO") - - # --- Send final status message to the queue --- - if return_code == 0: - dist_path_abs = os.path.join(working_dir, output_dir_name) - if os.path.exists(dist_path_abs): - success_msg = f"Build completato con successo!\nDirectory Output: {dist_path_abs}" - output_queue.put(("BUILD_SUCCESS", success_msg)) - else: - # Build reported success, but output dir is missing! - warn_msg = (f"Build terminato con codice 0, ma directory output non trovata:\n" - f"{dist_path_abs}\nControlla il log completo per eventuali problemi.") - logger_func(warn_msg, level="WARNING") - output_queue.put(("BUILD_ERROR", warn_msg)) # Report as error to user - else: - # Build failed - error_msg = f"Build fallito! (Codice Uscita: {return_code})\nControlla il log per dettagli." - output_queue.put(("BUILD_ERROR", error_msg)) - - except FileNotFoundError: - # Handle case where the pyinstaller command itself isn't found - error_msg = ("Errore: Comando 'pyinstaller' non trovato.\n" - "Assicurati che PyInstaller sia installato correttamente e nel PATH.") - output_queue.put(("BUILD_ERROR", error_msg)) - logger_func(error_msg, level="CRITICAL") - except Exception as e: - # Handle any other unexpected exceptions during subprocess management - error_msg = f"Errore inatteso durante l'esecuzione del processo di build: {e}" - output_queue.put(("BUILD_ERROR", error_msg)) - logger_func(error_msg, level="CRITICAL") - logger_func(traceback.format_exc(), level="DEBUG") # Log full traceback for debugging - finally: - # --- Crucial: Always signal that the build finished --- - # This allows the main thread to re-enable the build button etc. - output_queue.put(("BUILD_FINISHED", None)) # Send finish signal - logger_func("Build thread finished.", level="INFO") \ No newline at end of file diff --git a/config.py b/config.py deleted file mode 100644 index db9a284..0000000 --- a/config.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Configuration constants for the PyInstaller GUI Wrapper application. -""" - -# Default values for PyInstaller options managed by the GUI -# These are used initially and when resetting options. -DEFAULT_SPEC_OPTIONS = { - 'onefile': False, # Corresponds to --onefile/--onedir - 'windowed': True, # Corresponds to --windowed/--console (--noconsole) - 'name': '', # Application name, derived from script if empty - 'icon': '', # Path to the application icon (.ico on Win, .icns on Mac) - 'add_data': [], # List of tuples (source_path, destination_in_bundle) - 'hidden_imports': [], # List of modules PyInstaller might miss - 'log_level': 'INFO', # PyInstaller log verbosity - 'clean_build': True, # Corresponds to --clean (remove cache before build) - 'confirm_overwrite': False,# Corresponds to --noconfirm (True means --noconfirm is active) - 'output_dir_name': '_dist', # Custom name for the output directory (default is 'dist') - 'work_dir_name': '_build', # Custom name for the temporary build directory (default is 'build') - 'use_upx': True, # Whether to attempt using UPX if available - # Internal tracking, potentially populated by parser, used by generator - 'scripts': [], # Track main script(s) from Analysis - 'pathex': [], # Track pathex from Analysis - 'binaries': [], # Track binaries from Analysis -} - -# Log levels available in PyInstaller (for potential dropdown/validation) -PYINSTALLER_LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"] - -# Add other constants here as needed, e.g., file type filters -SCRIPT_FILE_TYPES = [ - ("Python files", "*.py"), - ("Pythonw files", "*.pyw"), - ("All files", "*.*") -] - -ICON_FILE_TYPES_WINDOWS = [ - ("Icon files", "*.ico"), - ("All files", "*.*") -] -ICON_DEFAULT_EXT_WINDOWS = ".ico" - -ICON_FILE_TYPES_MACOS = [ - ("Apple Icon files", "*.icns"), - ("PNG images", "*.png"), - ("All files", "*.*") -] -ICON_DEFAULT_EXT_MACOS = ".icns" - -ICON_FILE_TYPES_LINUX = [ - ("Image files", "*.png"), - ("Icon files", "*.ico"), - ("All files", "*.*") -] -ICON_DEFAULT_EXT_LINUX = ".png" \ No newline at end of file diff --git a/gui.py b/gui.py deleted file mode 100644 index 5c16568..0000000 --- a/gui.py +++ /dev/null @@ -1,460 +0,0 @@ -# -*- 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 # Needed for Add Data destination prompt -import os # Needed for path manipulation and environ -import sys # Needed for sys.platform -import threading -import queue -import traceback # For detailed error logging -import shutil # Needed for shutil.which() - -# Import other modules from the package/directory -import config -import spec_parser -import builder - -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__() - self.title("PyInstaller GUI Wrapper") - # Consider setting a minimum size, especially with the added tab - # self.minsize(650, 550) - - # --- Tkinter Variables (Basic Options) --- - self.python_script_path = tk.StringVar() - self.spec_file_path = tk.StringVar() - 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']) - - # --- Data Structures for Complex Options --- - # List to hold tuples: (absolute_source_path, destination_in_bundle) - 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("Applicazione inizializzata.", level="INFO") - - # --- Widget Creation --- - def _create_widgets(self, parent_frame): - """ Create all the necessary GUI widgets. """ - # --- Section 1: Input Script --- - self.file_frame = ttk.LabelFrame(parent_frame, text="Input Script") - self.script_label = ttk.Label(self.file_frame, text="Python Script:") - self.script_entry = ttk.Entry(self.file_frame, textvariable=self.python_script_path, state="readonly", width=70) - self.script_button_browse = ttk.Button(self.file_frame, text="Sfoglia...", command=self._select_script) - - # --- 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="Opzioni Base") - - self.name_label = ttk.Label(self.basic_options_frame, text="Nome App:") - self.name_entry = ttk.Entry(self.basic_options_frame, textvariable=self.app_name) - self.icon_label = ttk.Label(self.basic_options_frame, text="File Icona:") - self.icon_entry = ttk.Entry(self.basic_options_frame, textvariable=self.icon_path, state="readonly", width=60) - self.icon_button_browse = ttk.Button(self.basic_options_frame, text="Sfoglia...", command=self._select_icon) - self.icon_button_clear = ttk.Button(self.basic_options_frame, text="Pulisci", command=self._clear_icon) - self.onefile_check = ttk.Checkbutton(self.basic_options_frame, text="File Singolo (Unico eseguibile)", variable=self.is_onefile) - self.windowed_check = ttk.Checkbutton(self.basic_options_frame, text="Finestra (Nessuna console)", variable=self.is_windowed) - - # --- Tab 2: File Aggiuntivi --- - self.data_files_frame = ttk.Frame(self.options_notebook, padding="10") - self.options_notebook.add(self.data_files_frame, text="File Aggiuntivi") - - self.data_list_label = ttk.Label(self.data_files_frame, text="File/Cartelle da includere (Sorgente -> Destinazione nel 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, # Allow only one selection for removal - yscrollcommand=self.data_list_scrollbar_y.set, - xscrollcommand=self.data_list_scrollbar_x.set, - height=6, width=70 - ) - 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="Aggiungi File...", command=self._add_data_file) - self.data_button_add_dir = ttk.Button(self.data_buttons_frame, text="Aggiungi Cartella...", command=self._add_data_directory) - self.data_button_remove = ttk.Button(self.data_buttons_frame, text="Rimuovi Selezionato", command=self._remove_selected_data) - - - # --- Section 3: Build Output Log --- - self.output_frame = ttk.LabelFrame(parent_frame, text="Log di Build") - 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="Crea Eseguibile", command=self._start_build, state="disabled") - - - # --- Layout Widgets --- - def _layout_widgets(self): - """ Arrange widgets using grid layout. """ - self.main_frame.columnconfigure(0, weight=1) - self.main_frame.rowconfigure(1, weight=0) # Notebook row - self.main_frame.rowconfigure(2, weight=1) # Output log expands - - # Layout Script Selection - self.file_frame.grid(row=0, column=0, padx=5, pady=(0, 5), sticky="ew") - self.file_frame.columnconfigure(1, weight=1) - self.script_label.grid(row=0, column=0, padx=5, pady=5, sticky="w") - self.script_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew") - self.script_button_browse.grid(row=0, column=2, padx=5, pady=5) - - # Layout Options Notebook - self.options_notebook.grid(row=1, 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) # Listbox expands horizontally - self.data_files_frame.rowconfigure(1, weight=1) # Listbox expands vertically - 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 - self.output_frame.grid(row=2, 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 - self.action_frame.grid(row=3, column=0, padx=5, pady=(5, 0), sticky="e") - self.build_button.pack(pady=5) - - - # --- Callback Methods --- - - def _select_script(self): - """ Handles script selection, derives defaults, checks spec, updates GUI. """ - self._log_to_gui("="*20 + " SELEZIONE SCRIPT INIZIATA " + "="*20, level="INFO") - file_path = filedialog.askopenfilename(title="Seleziona Script Python", filetypes=config.SCRIPT_FILE_TYPES) - if not file_path: self._log_to_gui("Selezione script cancellata.", level="INFO"); return - - self.python_script_path.set(file_path) - self._log_to_gui(f"Selezionato script: {file_path}", level="INFO") - script_dir = os.path.dirname(file_path) - script_filename = os.path.basename(file_path) - script_name_without_ext = os.path.splitext(script_filename)[0] - potential_spec_path = os.path.join(script_dir, f"{script_name_without_ext}.spec") - self.spec_file_path.set(potential_spec_path) - - self._reset_options_to_defaults(derive_name=True) # Reset includes data list - self._handle_existing_spec_file(potential_spec_path) # Parse spec if exists - self.build_button.config(state="normal") - self._log_to_gui("Pronto. Rivedi le opzioni o clicca 'Crea Eseguibile'.", level="INFO") - - def _handle_existing_spec_file(self, spec_path): - """ Checks for and parses an existing spec file using spec_parser. """ - if os.path.exists(spec_path): - self._log_to_gui("-" * 15 + " Parsing Spec File Esistente " + "-" * 15, level="INFO") - self._log_to_gui(f"Trovato file spec esistente: {spec_path}", level="INFO") - 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) # Includes data list - messagebox.showinfo("Spec File Caricato", f"Caricate opzioni da:\n{spec_path}\nCampi GUI aggiornati.") - else: - messagebox.showwarning("Spec File Parsato - Opzioni non Riconosciute", f"Parsato file spec:\n{spec_path}\n\nNessuna opzione comune riconosciuta.\nBuild userà impostazioni GUI e sovrascriverà lo spec.") - else: - messagebox.showerror("Errore Parsing Spec File", f"Fallito il parsing del file spec:\n{spec_path}\n\nControlla log. Procedere sovrascriverà lo spec.") - else: - self._log_to_gui(f"Nessun file spec trovato in: {spec_path}. Ne verrà generato uno nuovo.", level="INFO") - - - def _reset_options_to_defaults(self, derive_name=True): - """ Resets GUI option widgets and internal data lists to defaults. """ - self._log_to_gui("Resetting opzioni ai valori di default.", level="INFO") - default_app_name = "" - if derive_name: - script_file = self.python_script_path.get() - if script_file: default_app_name = os.path.splitext(os.path.basename(script_file))[0] - - self.app_name.set(default_app_name or config.DEFAULT_SPEC_OPTIONS['name']) - self.icon_path.set(config.DEFAULT_SPEC_OPTIONS['icon']) - 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']) - - self.added_data_list.clear() - self.data_listbox.delete(0, tk.END) - self._log_to_gui("Lista file aggiuntivi resettata.", level="DEBUG") - - - def _update_gui_from_options(self, options): - """ Updates GUI widgets AND data lists based on parsed options. """ - self._log_to_gui("Aggiornamento GUI dalle opzioni spec caricate/parsate.", level="INFO") - # Update basic options - 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: self.icon_path.set(options['icon']) - else: self.icon_path.set("") - 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']) - - # Update Added Data list/listbox - self.added_data_list.clear() - self.data_listbox.delete(0, tk.END) - parsed_datas = options.get('datas', []) - if parsed_datas: - self._log_to_gui(f"Trovati 'datas' nello spec: {len(parsed_datas)} voci.", level="INFO") - script_dir = os.path.dirname(os.path.abspath(self.python_script_path.get())) if self.python_script_path.get() else "" - - for item in parsed_datas: - if isinstance(item, (tuple, list)) and len(item) == 2: - source, destination = item - abs_source = source - # Convert relative source paths based on the script's directory - if script_dir and isinstance(source, str) and not os.path.isabs(source): - abs_source = os.path.abspath(os.path.join(script_dir, source)) - self._log_to_gui(f" Risolto percorso relativo data: '{source}' -> '{abs_source}'", level="DEBUG") - - if isinstance(abs_source, str) and isinstance(destination, str): - self.added_data_list.append((abs_source, destination)) - self.data_listbox.insert(tk.END, f"{abs_source} -> {destination}") - else: self._log_to_gui(f" Ignorata voce 'datas' non valida (non stringhe): {item}", level="WARNING") - else: self._log_to_gui(f" Ignorata voce 'datas' non valida (formato non tupla/lista di 2): {item}", level="WARNING") - self._log_to_gui("Lista file aggiuntivi popolata dallo spec.", level="DEBUG") - else: self._log_to_gui("Nessuna voce 'datas' trovata o valida nello spec.", level="DEBUG") - - self._log_to_gui("Aggiornamento GUI dalle opzioni completato.", level="INFO") - - def _select_icon(self): - """ Handles icon file selection based on platform. """ - 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="Seleziona Icona Applicazione", filetypes=filetypes, defaultextension=default_ext) - if file_path: self.icon_path.set(file_path); self._log_to_gui(f"Selezionata icona: {file_path}") - - def _clear_icon(self): - """ Clears the icon selection. """ - self.icon_path.set("") - self._log_to_gui("Selezione icona rimossa.") - - # --- Methods for Added Data --- - - def _add_data_file(self): - """ Adds a selected file to the 'add_data' list. """ - source_path = filedialog.askopenfilename(title="Seleziona File Sorgente da Aggiungere") - if not source_path: self._log_to_gui("Aggiunta file annullata.", level="DEBUG"); return - abs_source_path = os.path.abspath(source_path) - dest_path = simpledialog.askstring("Destinazione nel Bundle", f"Percorso destinazione relativo per:\n{os.path.basename(abs_source_path)}\n(es. '.', 'data', 'images/subdir')", initialvalue=".") - if dest_path is None: self._log_to_gui("Aggiunta file annullata (destinazione).", level="DEBUG"); return - entry = (abs_source_path, dest_path); self.added_data_list.append(entry) - display_text = f"{abs_source_path} -> {dest_path}"; self.data_listbox.insert(tk.END, display_text) - self._log_to_gui(f"Aggiunto file dati: {display_text}", level="INFO") - - def _add_data_directory(self): - """ Adds a selected directory to the 'add_data' list. """ - source_path = filedialog.askdirectory(title="Seleziona Cartella Sorgente da Aggiungere") - if not source_path: self._log_to_gui("Aggiunta cartella annullata.", level="DEBUG"); return - abs_source_path = os.path.abspath(source_path) - default_dest = os.path.basename(abs_source_path) - dest_path = simpledialog.askstring("Destinazione nel Bundle", f"Percorso destinazione relativo per cartella:\n{default_dest}\n(es. '{default_dest}', 'data/{default_dest}')", initialvalue=default_dest) - if dest_path is None: self._log_to_gui("Aggiunta cartella annullata (destinazione).", level="DEBUG"); return - entry = (abs_source_path, dest_path); self.added_data_list.append(entry) - display_text = f"{abs_source_path} -> {dest_path}"; self.data_listbox.insert(tk.END, display_text) - self._log_to_gui(f"Aggiunta cartella dati: {display_text}", level="INFO") - - def _remove_selected_data(self): - """ Removes the selected item from the 'add_data' list. """ - selected_indices = self.data_listbox.curselection() - if not selected_indices: messagebox.showwarning("Nessuna Selezione", "Seleziona una voce da rimuovere."); return - index = selected_indices[0] - item_text = self.data_listbox.get(index); self.data_listbox.delete(index) - if index < len(self.added_data_list): removed_entry = self.added_data_list.pop(index); self._log_to_gui(f"Rimossa voce dati: {item_text}", level="INFO") - else: self._log_to_gui(f"Errore: Indice {index} fuori range per lista dati interna.", level="ERROR") - - # --- Logging and Queue Handling --- - - def _log_to_gui(self, message, level="INFO"): - """ Appends a formatted message to the output log area safely. """ - formatted_message = f"[{level}] {str(message).strip()}\n" - if threading.current_thread() is threading.main_thread(): self._update_output_log_widget(formatted_message) - else: self.build_queue.put(("LOG", formatted_message)) - - def _update_output_log_widget(self, message): - """ Internal method to append text to the ScrolledText widget. """ - 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] Errore aggiornamento log widget: {e}\n{traceback.format_exc()}") - - def _check_build_queue(self): - """ Periodically checks the build queue for messages from the builder thread. """ - try: - while True: - item = self.build_queue.get_nowait() - if isinstance(item, tuple) and len(item) == 2: - command, data = item - if command == "LOG": self._update_output_log_widget(data) - elif command == "BUILD_SUCCESS": self._log_to_gui(data, level="SUCCESS"); messagebox.showinfo("Build Completata", data); self._on_build_finished() - elif command == "BUILD_ERROR": self._log_to_gui(data, level="ERROR"); messagebox.showerror("Build Fallita", data); self._on_build_finished() - elif command == "BUILD_FINISHED": self._log_to_gui("Segnale completamento processo build ricevuto.", level="DEBUG") - else: self._log_to_gui(f"Comando sconosciuto dalla coda: {command}", level="WARNING") - else: self._log_to_gui(f"Elemento inatteso nella coda: {item}", level="WARNING") - self.build_queue.task_done() - except queue.Empty: pass - except Exception as e: self._log_to_gui(f"Errore processamento coda build: {e}\n{traceback.format_exc()}", level="CRITICAL") - finally: self.after(100, self._check_build_queue) - - def _on_build_finished(self): - """ Actions to take when build completes (success or failure). """ - self.build_button.config(state="normal") - self.build_thread = None - self._log_to_gui("Processo di build terminato. Controlli GUI riabilitati.", level="INFO") - - # --- Spec File Generation --- - def _generate_spec_content(self): - """ Generates the .spec file content including 'datas' from the GUI list. """ - script_path = self.python_script_path.get() - if not script_path or not os.path.exists(script_path): messagebox.showerror("Errore", "Percorso script Python invalido."); return None - self._log_to_gui("Generazione contenuto .spec dalle opzioni GUI...", level="INFO") - script_abspath = os.path.abspath(script_path); script_dir = os.path.dirname(script_abspath) - app_name = self.app_name.get() or os.path.splitext(os.path.basename(script_abspath))[0] - icon_path = self.icon_path.get() - icon_abspath = os.path.abspath(icon_path) if icon_path and os.path.exists(icon_path) else None - def escape_path(p): return p.replace("\\", "\\\\") if p else "" - - # --- Analysis Section --- - analysis_scripts = f"['{escape_path(script_abspath)}']" - analysis_pathex = f"['{escape_path(script_dir)}']" - # Use self.added_data_list to generate the 'datas' argument - formatted_datas = "[" + ",\n ".join([f"('{escape_path(src)}', '{escape_path(dst)}')" for src, dst in self.added_data_list]) + "]" - self._log_to_gui(f"Formattati {len(self.added_data_list)} voci 'datas' per lo spec.", level="DEBUG") - formatted_hiddenimports = "[]"; formatted_binaries = "[]" # Placeholders for now - - analysis_str = f"""a = Analysis(scripts={analysis_scripts}, pathex={analysis_pathex}, binaries={formatted_binaries}, datas={formatted_datas}, hiddenimports={formatted_hiddenimports}, hookspath=[], runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=None, noarchive=False,)""" - pyz_str = "pyz = PYZ(a.pure, a.zipped_data, cipher=None)" - exe_options = [f"name='{app_name}'", "debug=False", "bootloader_ignore_signals=False", "strip=False", f"upx={config.DEFAULT_SPEC_OPTIONS['use_upx']}", "upx_exclude=[]", "runtime_tmpdir=None", f"console={not self.is_windowed.get()}"] - if icon_abspath: exe_options.append(f"icon='{escape_path(icon_abspath)}'") - exe_options_str = ",\n ".join(exe_options) - exe_str = f"""exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], {exe_options_str},)""" - collect_str = "" - 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']}, upx_exclude=[], name='{app_name}',)""" - - spec_header = "# -*- mode: python ; coding: utf-8 -*-\n\nblock_cipher = None\n" - spec_content = f"{spec_header}\n{analysis_str}\n\n{pyz_str}\n\n{exe_str}\n{collect_str}" - self._log_to_gui("Contenuto file .spec generato.", level="DEBUG") - return spec_content.strip() + "\n" - - # --- Save Spec File --- - def _save_spec_file(self, content): - """ Saves the generated content to the .spec file path. """ - spec_path = self.spec_file_path.get() - if not spec_path: messagebox.showerror("Errore", "Impossibile salvare spec: percorso non impostato."); return False - self._log_to_gui(f"Tentativo salvataggio spec file su: {spec_path}", level="INFO") - try: - os.makedirs(os.path.dirname(spec_path), exist_ok=True) - with open(spec_path, 'w', encoding='utf-8') as f: f.write(content) - self._log_to_gui(f"File spec salvato con successo: {spec_path}", level="INFO"); return True - except Exception as e: - error_msg = f"Fallito salvataggio spec file '{spec_path}': {e}\n{traceback.format_exc()}" - self._log_to_gui(error_msg, level="ERROR"); messagebox.showerror("Errore Salvataggio File", error_msg); return False - - # --- Build Execution --- - def _start_build(self): - """ Validates, generates/saves spec, starts build thread using the 'pyinstaller' command - with a potentially cleaned environment for Tcl/Tk variables. """ - self._log_to_gui("="*20 + " PROCESSO DI BUILD INIZIATO " + "="*20, level="INFO") - - script_path = self.python_script_path.get(); spec_path = self.spec_file_path.get() - if not script_path or not os.path.exists(script_path): messagebox.showerror("Errore", "Seleziona uno script Python valido."); self._log_to_gui("Build annullata: script non valido.", level="ERROR"); return - if not spec_path: messagebox.showerror("Errore", "Errore interno: percorso spec non impostato."); self._log_to_gui("Build annullata: percorso spec non impostato.", level="ERROR"); return - - self._log_to_gui("Generazione contenuto spec dalle opzioni GUI...", level="INFO") - spec_content = self._generate_spec_content(); - if spec_content is None: self._log_to_gui("Build annullata: fallita generazione spec.", level="ERROR"); return - - self._log_to_gui("Salvataggio file spec...", level="INFO") - if not self._save_spec_file(spec_content): self._log_to_gui("Build annullata: fallito salvataggio spec.", level="ERROR"); return - - self._log_to_gui("Preparazione esecuzione PyInstaller...", level="INFO") - self.build_button.config(state="disabled") - script_dir = os.path.dirname(os.path.abspath(script_path)) - dist_path_abs = os.path.join(script_dir, config.DEFAULT_SPEC_OPTIONS['output_dir_name']) - work_path_abs = os.path.join(script_dir, config.DEFAULT_SPEC_OPTIONS['work_dir_name']) - - pyinstaller_cmd = shutil.which("pyinstaller") - if not pyinstaller_cmd: - error_msg = ("Comando 'pyinstaller' non trovato nel PATH...\n" - "Assicurati sia installato e nel PATH.") - self._log_to_gui(error_msg, level="CRITICAL"); messagebox.showerror("Errore PyInstaller", error_msg) - self.build_button.config(state="normal"); return - - self._log_to_gui(f"Trovato eseguibile PyInstaller in: {pyinstaller_cmd}", level="DEBUG") - command = [pyinstaller_cmd, spec_path, "--distpath", dist_path_abs, "--workpath", work_path_abs, "--log-level", self.log_level.get()] - if config.DEFAULT_SPEC_OPTIONS['clean_build']: command.append("--clean") - if not config.DEFAULT_SPEC_OPTIONS['confirm_overwrite']: command.append("--noconfirm") - - 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') - if removed_vars: self._log_to_gui(f"Rimosse variabili d'ambiente {removed_vars} per PyInstaller esterno.", level="DEBUG") - else: self._log_to_gui("Nessuna variabile Tcl/Tk ereditata trovata da rimuovere.", level="DEBUG") - - quoted_command = [f'"{arg}"' if ' ' in arg else arg for arg in command] - self._log_to_gui(f"Comando base: {' '.join(quoted_command)}", level="DEBUG") - self._log_to_gui(f"Directory di lavoro: {script_dir}", level="DEBUG") - - self.build_thread = threading.Thread( - target=builder.run_build_in_thread, - args=(command, script_dir, self.build_queue, self._log_to_gui, config.DEFAULT_SPEC_OPTIONS['output_dir_name'], build_env), # Passa ambiente - daemon=True - ) - self.build_thread.start() - self._log_to_gui("Thread di build avviato con ambiente personalizzato.", level="INFO") - -# --- End of PyInstallerGUI class --- - -# Note: The main execution block remains in CreateExecFromPy.py -# if __name__ == "__main__": -# app = PyInstallerGUI() -# app.mainloop() \ No newline at end of file diff --git a/pyinstallerguiwrapper.spec b/pyinstallerguiwrapper.spec new file mode 100644 index 0000000..3fefc9d --- /dev/null +++ b/pyinstallerguiwrapper.spec @@ -0,0 +1,63 @@ +# -*- 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) + +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 +) + +# 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/ +) + +# 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 diff --git a/CreateExecFromPy.py b/pyinstallerguiwrapper/CreateExecFromPy.py similarity index 77% rename from CreateExecFromPy.py rename to pyinstallerguiwrapper/CreateExecFromPy.py index ed5b3b1..e32dd17 100644 --- a/CreateExecFromPy.py +++ b/pyinstallerguiwrapper/CreateExecFromPy.py @@ -8,12 +8,12 @@ Run this file to launch the application. import sys import os -import traceback # Import traceback for better error reporting +import traceback # Import traceback for better error reporting # --- Crucial: Add the directory containing the modules to sys.path --- # This allows Python to find 'gui.py', 'config.py', etc. when run directly. script_dir = os.path.dirname(os.path.abspath(__file__)) -print(f"Adding to sys.path: {script_dir}") # Debug print +print(f"Adding to sys.path: {script_dir}") # Debug print if script_dir not in sys.path: sys.path.insert(0, script_dir) @@ -22,6 +22,7 @@ if script_dir not in sys.path: try: # Import the main GUI class directly by name from gui import PyInstallerGUI + # We might need other modules here later if needed at top level # import config # import builder @@ -30,7 +31,7 @@ except ImportError as e: print(f"ERROR: Could not import required modules.") print(f"ImportError: {e}") print(f"Current sys.path: {sys.path}") - traceback.print_exc() # Print detailed traceback + traceback.print_exc() # Print detailed traceback sys.exit(1) except Exception as e: print(f"ERROR: An unexpected error occurred during initial imports: {e}") @@ -46,22 +47,29 @@ def main(): app = PyInstallerGUI() app.mainloop() except Exception as e: - print(f"FATAL ERROR: An unexpected error occurred during application execution: {e}") + print( + f"FATAL ERROR: An unexpected error occurred during application execution: {e}" + ) traceback.print_exc() try: # Attempt to show a Tkinter error box if possible import tkinter as tk from tkinter import messagebox + root = tk.Tk() root.withdraw() - messagebox.showerror("Fatal Error", f"Application failed to run:\n{e}\n\nSee console for details.") + messagebox.showerror( + "Fatal Error", + f"Application failed to run:\n{e}\n\nSee console for details.", + ) root.destroy() except Exception: - pass # Ignore if Tkinter fails here too + pass # Ignore if Tkinter fails here too sys.exit(1) + # --- Main Execution Guard --- if __name__ == "__main__": # Check Python version if needed (Tkinter themes benefit from newer versions) # print(f"Running with Python {sys.version}") - main() \ No newline at end of file + main() diff --git a/pyinstallerguiwrapper/__init__.py b/pyinstallerguiwrapper/__init__.py new file mode 100644 index 0000000..498780d --- /dev/null +++ b/pyinstallerguiwrapper/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Intentionally left blank to indicate this directory is a Python package. diff --git a/pyinstallerguiwrapper/__main__.py b/pyinstallerguiwrapper/__main__.py new file mode 100644 index 0000000..40da0b1 --- /dev/null +++ b/pyinstallerguiwrapper/__main__.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +""" +Main entry point script for the PyInstaller GUI Wrapper application when run as a module. +""" + +import sys +import os +import traceback # Import traceback for better error reporting + +# --- No sys.path manipulation needed when running as a module --- +# Python handles the package structure automatically. + +# --- Import the necessary modules --- +try: + # MODIFIED: Use relative import to get the GUI class from within the same package + from .gui import PyInstallerGUI +except ImportError as e: + # Provide more context in case of import errors + package_dir = os.path.dirname(os.path.abspath(__file__)) + print(f"ERROR: Could not import required modules from package.") + print(f" Attempted relative import within: {package_dir}") + print(f"ImportError: {e}") + print(f"Current sys.path: {sys.path}") + traceback.print_exc() # Print detailed traceback + sys.exit(1) +except Exception as e: + print(f"ERROR: An unexpected error occurred during initial imports: {e}") + traceback.print_exc() + sys.exit(1) + + +def main(): + """ + Initializes and runs the PyInstaller GUI application. + """ + try: + app = PyInstallerGUI() + app.mainloop() + except Exception as e: + # MODIFIED: Error message context updated + print( + f"FATAL ERROR: An unexpected error occurred during application execution: {e}" + ) + traceback.print_exc() + try: + # Attempt to show a Tkinter error box if possible + import tkinter as tk + from tkinter import messagebox + + root = tk.Tk() + root.withdraw() + # MODIFIED: Messagebox content translated/updated + messagebox.showerror( + "Fatal Error", + f"Application failed to run:\n{e}\n\nSee console for details.", + ) + root.destroy() + except Exception: + pass # Ignore if Tkinter fails here too + sys.exit(1) + + +# --- Main Execution Guard --- +# This guard is still useful if you were to potentially import `__main__` elsewhere, +# though it's less critical when the primary execution path is `python -m`. +if __name__ == "__main__": + # print(f"Running PyInstallerGUIWrapper via __main__.py with Python {sys.version}") + main() diff --git a/pyinstallerguiwrapper/builder.py b/pyinstallerguiwrapper/builder.py new file mode 100644 index 0000000..f491cf8 --- /dev/null +++ b/pyinstallerguiwrapper/builder.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +""" builder.py - Handles the execution of the PyInstaller build process in a separate thread. """ + +import subprocess +import threading +import queue +import os +import traceback # For logging exceptions + +# NO CHANGES NEEDED in this file for the restructuring + +def run_build_in_thread(command, working_dir, output_queue, logger_func, output_dir_name, environment=None): + """ + Executes the PyInstaller command using subprocess.Popen in the background. + ... (docstring remains the same) ... + """ + # ... (Function body remains the same as the previously translated version) ... + build_process = None + try: + logger_func("Build thread starting execution...", level="INFO") + env_log_msg = f"Using {'custom' if environment else 'inherited'} environment." + if environment: pass + logger_func(f"PyInstaller process starting. {env_log_msg}", level="INFO") + build_process = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=working_dir, + text=True, encoding='utf-8', errors='replace', bufsize=1, env=environment ) + logger_func(f"PyInstaller process launched (PID: {build_process.pid}).", level="INFO") + while True: + try: + line = build_process.stdout.readline() + if not line: + if build_process.poll() is not None: + logger_func("End of PyInstaller output stream.", level="DEBUG") + break + else: pass + else: output_queue.put(("LOG", line)) + except Exception as read_err: + logger_func(f"Error reading PyInstaller output stream: {read_err}\n{traceback.format_exc()}", level="ERROR") + break + return_code = build_process.wait() + logger_func(f"PyInstaller process finished with exit code: {return_code}", level="INFO") + if return_code == 0: + dist_path_abs = os.path.join(working_dir, output_dir_name) + if os.path.exists(dist_path_abs): + success_msg = f"Build completed successfully!\nOutput Directory: {dist_path_abs}" + output_queue.put(("BUILD_SUCCESS", success_msg)) + else: + warn_msg = (f"Build finished with exit code 0, but output directory not found:\n" + f"{dist_path_abs}\nCheck the full log for potential issues.") + logger_func(warn_msg, level="WARNING") + output_queue.put(("BUILD_ERROR", warn_msg)) + else: + error_msg = f"Build failed! (Exit Code: {return_code})\nCheck the log for details." + output_queue.put(("BUILD_ERROR", error_msg)) + except FileNotFoundError: + error_msg = ("Error: 'pyinstaller' command not found.\n" + "Ensure PyInstaller is installed correctly and in the system PATH.") + output_queue.put(("BUILD_ERROR", error_msg)) + logger_func(error_msg, level="CRITICAL") + except Exception as e: + error_msg = f"Unexpected error during build process execution: {e}" + output_queue.put(("BUILD_ERROR", error_msg)) + logger_func(error_msg, level="CRITICAL") + logger_func(traceback.format_exc(), level="DEBUG") + finally: + output_queue.put(("BUILD_FINISHED", None)) + logger_func("Build thread finished.", level="INFO") \ No newline at end of file diff --git a/pyinstallerguiwrapper/config.py b/pyinstallerguiwrapper/config.py new file mode 100644 index 0000000..9e40c76 --- /dev/null +++ b/pyinstallerguiwrapper/config.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +""" +Configuration constants for the PyInstaller GUI Wrapper application. +""" + +# NO CHANGES NEEDED in this file for the restructuring + +# Default values for PyInstaller options managed by the GUI +DEFAULT_SPEC_OPTIONS = { + 'onefile': False, + 'windowed': True, # Corresponds to --windowed/--console (--noconsole) + 'name': '', + 'icon': '', + 'add_data': [], + 'hidden_imports': [], + 'log_level': 'INFO', + 'clean_build': True, # Corresponds to --clean + 'confirm_overwrite': False,# Corresponds to --noconfirm (True means --noconfirm is active) + 'output_dir_name': '_dist', # Default name for the output directory + 'work_dir_name': '_build', # Default name for the temporary build directory + 'use_upx': True, + # Internal tracking - Populated by parser, used by GUI/generator + 'scripts': [], + 'pathex': [], + 'binaries': [], + 'datas': [], # Changed from 'add_data' to match PyInstaller terminology +} + +# Log levels available in PyInstaller +PYINSTALLER_LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"] + +# File type filters (Descriptions are already English) +SCRIPT_FILE_TYPES = [ + ("Python files", "*.py"), + ("Pythonw files", "*.pyw"), + ("All files", "*.*") +] + +ICON_FILE_TYPES_WINDOWS = [ + ("Icon files", "*.ico"), + ("All files", "*.*") +] +ICON_DEFAULT_EXT_WINDOWS = ".ico" + +ICON_FILE_TYPES_MACOS = [ + ("Apple Icon files", "*.icns"), + ("PNG images", "*.png"), + ("All files", "*.*") +] +ICON_DEFAULT_EXT_MACOS = ".icns" + +ICON_FILE_TYPES_LINUX = [ + ("Image files", "*.png"), + ("Icon files", "*.ico"), + ("All files", "*.*") +] +ICON_DEFAULT_EXT_LINUX = ".png" \ No newline at end of file diff --git a/pyinstallerguiwrapper/gui.py b/pyinstallerguiwrapper/gui.py new file mode 100644 index 0000000..e0a1ee1 --- /dev/null +++ b/pyinstallerguiwrapper/gui.py @@ -0,0 +1,1031 @@ +# -*- 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 + +# 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' + + +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__() + 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() + + # --- Derived Paths (Internal state) --- + self._derived_spec_path = "" + self._derived_icon_path = "" + self._derived_main_script_path = "" + self._derived_source_dir_path = "" + self._project_root_name = "" + + # --- 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"]) + + # --- Data Structures --- + 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() + + self.after(100, self._check_build_queue) + self._log_to_gui( + "Application initialized. Select the project directory.", level="INFO" + ) + + # ... (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" + ) + # --- 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): + # ... (code from previous step) ... + 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): + # ... (code from previous step) ... + 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() + 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" + ) + valid_structure = True + missing_files = [] + 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 + 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 + 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)) + else: + self.derived_icon_label_val.config( + text="Not found (optional)", foreground="grey" + ) + self.icon_path.set("") + 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._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.app_name.set(self._project_root_name) + 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 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): + # ... (code from previous step) ... + 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 + ) + 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.", + ) + 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: + 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) ... + self._log_to_gui("Resetting options to default values.", level="INFO") + default_app_name = "" + 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"]) + 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) ... + 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"]) + 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 + 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 "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.added_data_list.clear() + self.data_listbox.delete(0, tk.END) + 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: + if isinstance(item, (tuple, list)) and len(item) == 2: + source_rel, destination = item + if isinstance(source_rel, str) and isinstance(destination, str): + self.added_data_list.append((source_rel, destination)) + display_text = f"{source_rel} -> {destination}" + self.data_listbox.insert(tk.END, display_text) + 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: + 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" + ) + else: + 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) ... + 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, + ) + if file_path: + self.icon_path.set(file_path) + self._log_to_gui( + f"Icon selected manually (overrides): {file_path}", level="INFO" + ) + + def _clear_icon(self): + # ... (code from previous step) ... + self.icon_path.set("") + self._log_to_gui("Icon selection cleared.") + 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" + ) + else: + self.derived_icon_label_val.config( + text="Not found (optional)", foreground="grey" + ) + + def _add_data_file(self): + # ... (code from previous step) ... + 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 + ) + if not source_path: + self._log_to_gui("Add file cancelled.", level="DEBUG") + return + try: + 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) + ) + 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: + 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: + messagebox.showerror("Error", f"Unexpected error processing path: {e}") + 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=".", + ) + if dest_path is None: + self._log_to_gui("Add file cancelled (destination).", level="DEBUG") + return + entry = (relative_source_path, dest_path) + self.added_data_list.append(entry) + 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): + # ... (code from previous step) ... + 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 + ) + if not source_path: + self._log_to_gui("Add folder cancelled.", level="DEBUG") + return + try: + 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) + ) + 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: + 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: + messagebox.showerror("Error", f"Unexpected error processing path: {e}") + 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, + ) + if dest_path is None: + self._log_to_gui("Add folder cancelled (destination).", level="DEBUG") + return + entry = (relative_source_path, dest_path) + self.added_data_list.append(entry) + 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): + # ... (code from previous step) ... + selected_indices = self.data_listbox.curselection() + if not selected_indices: + messagebox.showwarning("No Selection", "Select an item to remove.") + return + index = selected_indices[0] + item_text = self.data_listbox.get(index) + self.data_listbox.delete(index) + 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", + ) + + # --- Logging and Queue Handling --- + def _log_to_gui(self, message, level="INFO"): + # ... (code from previous step) ... + formatted_message = f"[{level}] {str(message).strip()}\n" + if threading.current_thread() is threading.main_thread(): + self._update_output_log_widget(formatted_message) + else: + 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()}") + + def _check_build_queue(self): + # ... (code from previous step) ... + try: + while True: + item = self.build_queue.get_nowait() + if isinstance(item, tuple) and len(item) == 2: + command, data = item + 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() + elif command == "BUILD_ERROR": + self._log_to_gui(data, level="ERROR") + messagebox.showerror("Build Failed", data) + self._on_build_finished() + elif command == "BUILD_FINISHED": + self._log_to_gui( + "Build process finished signal received.", level="DEBUG" + ) + self._on_build_finished() + else: + 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" + ) + self.build_queue.task_done() + except queue.Empty: + pass + except Exception as e: + self._log_to_gui( + f"Error processing build queue: {e}\n{traceback.format_exc()}", + level="CRITICAL", + ) + finally: + 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 _generate_spec_content(self): + # ... (code from previous step, using corrected EXE/COLLECT structure) ... + project_dir = self.project_directory_path.get() + 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() + 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 + 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 = "." + app_name_val = self.app_name.get() or self._project_root_name + 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 + + def escape_path(p): + return p.replace("\\", "\\\\") if p else "" + + 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 = "[]" + 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_str = "pyz = PYZ(a.pure, a.zipped_data, cipher=None)" + exe_str = f"""exe = EXE(pyz, + a.scripts, + [], + exclude_binaries=True, + name='{app_name_val}', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx={config.DEFAULT_SPEC_OPTIONS['use_upx']}, + runtime_tmpdir=None, + console={not self.is_windowed.get()}, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None""" + if icon_rel_path: + exe_str += f",\n icon='{escape_path(icon_rel_path)}'" + exe_str += ")" + collect_str = "" + 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']}, + upx_exclude=[], + name='{app_name_val}')""" + spec_header = "# -*- mode: python ; coding: utf-8 -*-\n\nblock_cipher = None\n" + 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" + ) + if collect_str: + spec_content += f"\n{collect_str}\n" + self._log_to_gui(".spec file content generated.", level="DEBUG") + return spec_content.strip() + "\n" + + # --- Save Spec File --- + def _save_spec_file(self, content): + # ... (code from previous step) ... + if not self._derived_spec_path: + messagebox.showerror( + "Error", "Cannot save spec: derived path not set or invalid." + ) + return False + 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: + os.makedirs(os.path.dirname(spec_path_to_save), exist_ok=True) + 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 + except Exception as e: + 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): + # ... (code from previous step) ... + self._log_to_gui("=" * 20 + " BUILD PROCESS STARTED " + "=" * 20, level="INFO") + project_dir = self.project_directory_path.get() + 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" + ) + spec_content = self._generate_spec_content() + 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", + ) + if not self._save_spec_file(spec_content): + self._log_to_gui("Build cancelled: spec save failed.", level="ERROR") + return + 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"] + ) + 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() + return + self._log_to_gui( + f"Found PyInstaller executable at: {pyinstaller_cmd_path}", level="DEBUG" + ) + command = [ + pyinstaller_cmd_path, + str(self._derived_spec_path), + "--distpath", + dist_path_abs, + "--workpath", + work_path_abs, + "--log-level", + self.log_level.get(), + ] + if config.DEFAULT_SPEC_OPTIONS["clean_build"]: + command.append("--clean") + if not config.DEFAULT_SPEC_OPTIONS["confirm_overwrite"]: + command.append("--noconfirm") + 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") + 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", + ) + 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") + 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, + ), + daemon=True, + ) + self.build_thread.start() + self._log_to_gui("Build thread started.", level="INFO") + + +# --- End of PyInstallerGUI class --- diff --git a/spec_parser.py b/pyinstallerguiwrapper/spec_parser.py similarity index 52% rename from spec_parser.py rename to pyinstallerguiwrapper/spec_parser.py index 48902c3..59fc723 100644 --- a/spec_parser.py +++ b/pyinstallerguiwrapper/spec_parser.py @@ -7,210 +7,152 @@ Provides a safe way to extract configuration without executing the spec file. import ast import os +# NO CHANGES NEEDED in this file for the restructuring + class _SpecVisitor(ast.NodeVisitor): """ An AST Node Visitor to traverse the Python code of a .spec file and extract relevant PyInstaller configuration details safely. - Reduces logging noise by being more selective about evaluation attempts. """ def __init__(self, logger_func=print): - """ - Initialize the visitor. - - Args: - logger_func (callable): Function for logging (takes message, level). - """ self.parsed_options = {} self.logger = logger_func self._variable_assignments = {} def _log(self, message, level="DEBUG"): - """ Helper to call the logger function. """ try: self.logger(message, level=level) except TypeError: self.logger(f"[{level}] {message}") def _evaluate_node(self, node): - """ - Attempts to evaluate the value of an AST node more quietly. - Handles constants, simple lists/tuples/dicts, and tracked variables. - Returns evaluated Python value, or None if evaluation is complex/unsafe. - Logs warnings ONLY for unresolved variables or errors within structures. - """ - # --- Handle Simple Constants --- - if isinstance(node, ast.Constant): # Python 3.8+ - return node.value - elif isinstance(node, (ast.Str, ast.Num, ast.NameConstant)): # Python < 3.8 compat - # Handle deprecated nodes + # ... (Function body remains the same as previous version) ... + if isinstance(node, ast.Constant): return node.value + elif isinstance(node, (ast.Str, ast.Num, ast.NameConstant)): if isinstance(node, ast.Str): return node.s if isinstance(node, ast.Num): return node.n - return node.value # For True, False, None - - # --- Handle Simple Lists/Tuples --- + return node.value elif isinstance(node, (ast.List, ast.Tuple)): try: elements = [self._evaluate_node(el) for el in node.elts] - # Check if ANY element failed evaluation (returned None) if any(el is None for el in elements): - # Log warning here, as we expected to evaluate the elements self._log(f"Could not evaluate all elements in list/tuple: {ast.dump(node)}", level="WARNING") - return None # Cannot reliably evaluate the whole structure - # If all elements evaluated, return the list/tuple + return None return elements except Exception as e: self._log(f"Error evaluating list/tuple contents: {e} in {ast.dump(node)}", level="WARNING") return None - - # --- Handle Simple Dictionaries --- elif isinstance(node, ast.Dict): try: keys = [self._evaluate_node(k) for k in node.keys] values = [self._evaluate_node(v) for v in node.values] - # Check if ANY key or value failed evaluation if any(k is None for k in keys) or any(v is None for v in values): self._log(f"Could not evaluate all keys/values in dict: {ast.dump(node)}", level="WARNING") return None - # If all evaluated, return the dict return dict(zip(keys, values)) except Exception as e: self._log(f"Error evaluating dict contents: {e} in {ast.dump(node)}", level="WARNING") return None - - # --- Handle Variable Names --- elif isinstance(node, ast.Name): if node.id in self._variable_assignments: return self._variable_assignments[node.id] else: - # Log warning here, as we failed to resolve a name we encountered self._log(f"Cannot resolve variable name during evaluation: {node.id}", level="WARNING") return None - - # --- Handle Other Node Types Silently --- else: - # For types like ast.Call, ast.Attribute, ast.BinOp, etc., - # we assume they are too complex to evaluate safely or are not - # simple constants we care about. Return None without logging a warning. - # self._log(f"Skipping evaluation for node type: {type(node)}", level="DEBUG") # Optional debug log - return None + return None # Skip complex nodes quietly def visit_Assign(self, node): - """ - Visits assignment statements. Tries to track assignments only if the - value appears to be a simple constant or structure. Avoids warnings - for assignments like 'a = Analysis(...)'. - """ - # Check if assignment is to a single variable name + # ... (Function body remains the same as previous version) ... if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): var_name = node.targets[0].id - - # --- Check if the value is likely evaluatable BEFORE calling _evaluate_node --- - # Only proceed if it looks like a constant, simple structure, or known variable name if isinstance(node.value, (ast.Constant, ast.Str, ast.Num, ast.NameConstant, ast.List, ast.Tuple, ast.Dict, ast.Name)): value = self._evaluate_node(node.value) if value is not None: - # Successfully evaluated, track the assignment self._variable_assignments[var_name] = value self._log(f"Tracked assignment: {var_name} = {repr(value)}") else: - # Evaluation attempted but failed (e.g., unresolved name inside list) - # Log a warning because we expected to evaluate this type self._log(f"Could not evaluate value for assignment: {var_name} = {ast.dump(node.value)}", level="WARNING") - # else: - # If node.value is e.g. an ast.Call like Analysis(), PYZ(), EXE(), - # do nothing - don't try to evaluate, don't log warning. - # self._log(f"Skipping tracking for complex assignment: {var_name}", level="DEBUG") # Optional debug log - - # Continue traversal regardless of whether we tracked the assignment self.generic_visit(node) + def visit_Call(self, node): - """ Visits function call nodes (e.g., Analysis(...), EXE(...)). """ + # ... (Function body remains the same as previous version) ... if isinstance(node.func, ast.Name): call_name = node.func.id - options = {} # Extracted args for this call - - # --- Parse keyword arguments --- + options = {} for keyword in node.keywords: arg_name = keyword.arg value = self._evaluate_node(keyword.value) - if value is not None: - options[arg_name] = value - else: - # Log evaluation failures for keyword args as WARNING, - # as these are more likely intended to be simple values. - self._log(f"Could not evaluate keyword argument '{arg_name}' for call '{call_name}'. Value: {ast.dump(keyword.value)}", level="WARNING") + if value is not None: options[arg_name] = value + else: self._log(f"Could not evaluate keyword argument '{arg_name}' for call '{call_name}'. Value: {ast.dump(keyword.value)}", level="WARNING") - # --- Parse positional arguments --- - # We evaluate them mainly to potentially populate fields like 'scripts' - # if they aren't provided as keywords. Failure to evaluate these - # (e.g., 'pyz' in EXE(pyz, ...)) is less critical, maybe log as DEBUG. positional_args = [] for i, arg_node in enumerate(node.args): arg_value = self._evaluate_node(arg_node) positional_args.append(arg_value) - # Optional: Log failure for positional args at DEBUG level - # if arg_value is None: - # self._log(f"Could not evaluate positional argument {i} for call '{call_name}'. Value: {ast.dump(arg_node)}", level="DEBUG") - - # --- Process based on function name --- - # (Logic for Analysis, EXE, COLLECT remains the same) if call_name == 'Analysis': self._log(f"Found Analysis() call.") - # Prioritize keyword args, fallback to positional self.parsed_options['scripts'] = options.get('scripts') if self.parsed_options['scripts'] is None and len(positional_args) > 0: self.parsed_options['scripts'] = positional_args[0] - - self.parsed_options['pathex'] = options.get('pathex', []) # Default to empty list + self.parsed_options['pathex'] = options.get('pathex', []) if not self.parsed_options['pathex'] and len(positional_args) > 1: - self.parsed_options['pathex'] = positional_args[1] or [] # Use empty list if None - + self.parsed_options['pathex'] = positional_args[1] or [] self.parsed_options['datas'] = options.get('datas', []) self.parsed_options['hiddenimports'] = options.get('hiddenimports', []) self.parsed_options['binaries'] = options.get('binaries', []) - elif call_name == 'EXE': - self._log(f"Found EXE() call.") - self.parsed_options['name'] = options.get('name') - self.parsed_options['icon'] = options.get('icon') - console_val = options.get('console') - if console_val is True: self.parsed_options['windowed'] = False - elif console_val is False: self.parsed_options['windowed'] = True - # If 'console' is None (not present or unevaluatable), 'windowed' remains unset here - - # Assume onefile=True initially - self.parsed_options['onefile'] = True - + self._log(f"Found EXE() call.") + # Use .get() with default None for safety + self.parsed_options['name'] = options.get('name') + # Handle icon path carefully (might be None or empty string) + icon_val = options.get('icon') + self.parsed_options['icon'] = icon_val if icon_val else None # Store None if empty or not present + console_val = options.get('console') + if console_val is True: self.parsed_options['windowed'] = False + elif console_val is False: self.parsed_options['windowed'] = True + # Assume onefile=True unless COLLECT is found + if 'onefile' not in self.parsed_options: # Avoid overwriting if set by COLLECT already + self.parsed_options['onefile'] = True # Default assumption for EXE only elif call_name == 'COLLECT': self._log(f"Found COLLECT() call.") - # COLLECT overrides the onefile assumption - self.parsed_options['onefile'] = False - - # Continue traversal + self.parsed_options['onefile'] = False # COLLECT means it's not onefile self.generic_visit(node) + def get_options(self): - """ Returns the dictionary of parsed options. Normalize paths if needed. """ - # Normalize icon path if found + # ... (Function body remains the same as previous version) ... if 'icon' in self.parsed_options and self.parsed_options['icon']: try: - self.parsed_options['icon'] = os.path.normpath(self.parsed_options['icon']) + # Ensure it's a string before normpath + if isinstance(self.parsed_options['icon'], str): + self.parsed_options['icon'] = os.path.normpath(self.parsed_options['icon']) + else: # If icon is somehow not a string, log and remove + self._log(f"Invalid type for icon path: {type(self.parsed_options['icon'])}. Removing.", level="WARNING") + self.parsed_options['icon'] = None except Exception as e: self._log(f"Could not normalize icon path: {self.parsed_options['icon']} ({e})", level="WARNING") + self.parsed_options['icon'] = None # Set to None on error + # Ensure 'datas' is always a list, even if missing or None from spec + self.parsed_options['datas'] = self.parsed_options.get('datas', []) + if not isinstance(self.parsed_options['datas'], list): + self._log(f"Invalid type for 'datas' in spec ({type(self.parsed_options['datas'])}), resetting to empty list.", level="WARNING") + self.parsed_options['datas'] = [] + return self.parsed_options -# --- The `parse_spec_file` function remains the same --- + def parse_spec_file(spec_path, logger_func=print): """ Safely parses a .spec file using AST. """ - # (No changes needed in this function itself) + # ... (Function body remains the same as previous version) ... logger_func(f"Attempting to parse spec file using AST: {spec_path}", level="INFO") try: with open(spec_path, 'r', encoding='utf-8') as f: content = f.read() tree = ast.parse(content, filename=spec_path) - visitor = _SpecVisitor(logger_func=logger_func) # Use updated Visitor + visitor = _SpecVisitor(logger_func=logger_func) visitor.visit(tree) parsed_options = visitor.get_options() if not parsed_options: diff --git a/tool_config.json b/tool_config.json index 138b785..a9aec9f 100644 --- a/tool_config.json +++ b/tool_config.json @@ -1,8 +1,8 @@ { - "display_name": "PyInstaller GUI", - "description": "Crea eseguibili da script Python usando PyInstaller con una GUI.", - "command": ["python", "CreateExecFromPy.py"], - "version": "1.0", + "display_name": "PyInstaller GUI Wrapper", + "description": "Creates executables from Python projects using PyInstaller with a GUI.", + "command": ["python", "-m", "pyinstallerguiwrapper"], + "version": "1.1", "parameters": [], "has_gui": true } \ No newline at end of file