# File: pyinstallerguiwrapper/gui/project_selector.py # -*- coding: utf-8 -*- """ Manages project directory selection and derives key paths for the PyInstaller GUI Wrapper. """ import os import sys import pathlib import tkinter as tk from tkinter import filedialog, ttk, messagebox from typing import Optional, Callable, Dict, Any, Tuple, Union # Importamos config desde el paquete principal, no desde gui/ from pyinstallerguiwrapper import config # Definizione di un logger di default per ProjectManager se non ne viene passato uno def _default_logger(message: str, level: str = "INFO") -> None: """Default logger for ProjectManager if none is provided.""" # Sostituire con un vero logger di logging.Logger se necessario # print(f"[{level}] ProjectManager: {message}") pass class ProjectManager: """ Handles the selection of the main project directory and derivation of key paths (main script, .spec file, icon). Manages associated Tkinter variables and GUI labels. """ def __init__( self, parent_gui: tk.Tk, # Riferimento alla finestra principale per i dialoghi project_dir_var: tk.StringVar, derived_script_label_val: ttk.Label, derived_spec_label_val: ttk.Label, derived_icon_label_val: ttk.Label, icon_path_var: tk.StringVar, # La variabile Tkinter per il path dell'icona (che viene auto-riempita) logger_func: Optional[Callable[..., None]] = None, on_project_selected_callback: Optional[ Callable[[str, Dict[str, Any]], None] ] = None, ): """ Initializes the ProjectManager. Args: parent_gui: The main Tkinter window (for dialogs). project_dir_var: The StringVar for the selected project directory. derived_script_label_val: The Label widget for displaying the main script path. derived_spec_label_val: The Label widget for displaying the .spec file path. derived_icon_label_val: The Label widget for displaying the icon path. icon_path_var: The StringVar for the user-selected icon path (auto-filled on project select). logger_func: A function to use for logging messages. on_project_selected_callback: A callback function (project_root_str: str, derived_paths: Dict[str, Any]) -> None to be called after a project is successfully selected and paths derived. """ self.parent_gui = parent_gui self.project_directory_path = project_dir_var self.derived_script_label_val = derived_script_label_val self.derived_spec_label_val = derived_spec_label_val self.derived_icon_label_val = derived_icon_label_val self.icon_path_var = icon_path_var self.logger = logger_func if logger_func else _default_logger self.on_project_selected_callback = on_project_selected_callback # Store derived paths as attributes for easy access within the class self.derived_main_script_path: Optional[str] = None self.derived_spec_path: Optional[str] = None self.derived_icon_path: Optional[str] = None self.derived_source_dir_path: Optional[pathlib.Path] = None # pathlib.Path object self.project_root_name: Optional[str] = None def _log(self, message: str, level: str = "INFO") -> None: """Helper for logging messages with a consistent prefix.""" try: self.logger(f"[ProjectManager] {message}", level=level) except TypeError: self.logger(f"[{level}][ProjectManager] {message}") def _find_source_folders_with_main(self, project_root: pathlib.Path) -> list[pathlib.Path]: """ Finds all direct subdirectories of the project root that contain a __main__.py file. Args: project_root: The root directory of the project. Returns: A list of Path objects representing subdirectories containing __main__.py. """ candidates = [] try: for item in project_root.iterdir(): if item.is_dir() and not item.name.startswith('.') and not item.name.startswith('_'): main_script = item / "__main__.py" if main_script.is_file(): candidates.append(item) self._log(f"Found candidate source folder: {item.name}", level="DEBUG") except Exception as e: self._log(f"Error scanning directory: {e}", level="ERROR") return candidates def _ask_user_for_source_folder(self, project_root: pathlib.Path, candidates: list[pathlib.Path]) -> Optional[pathlib.Path]: """ Asks the user to select the correct source folder either from candidates or by manual selection. Args: project_root: The root directory of the project. candidates: List of candidate folders found automatically. Returns: The selected source folder Path, or None if cancelled. """ dialog = tk.Toplevel(self.parent_gui) dialog.title("Select Source Folder") dialog.geometry("600x400") dialog.transient(self.parent_gui) dialog.grab_set() selected_folder = None def on_select(): nonlocal selected_folder selection = listbox.curselection() if selection: idx = selection[0] if idx < len(candidates): selected_folder = candidates[idx] dialog.destroy() def on_browse(): nonlocal selected_folder folder = filedialog.askdirectory( title="Select the source folder containing __main__.py", initialdir=str(project_root), parent=dialog ) if folder: folder_path = pathlib.Path(folder) # Verify it contains __main__.py if (folder_path / "__main__.py").is_file(): selected_folder = folder_path dialog.destroy() else: messagebox.showerror( "Invalid Selection", f"The selected folder does not contain a __main__.py file.", parent=dialog ) def on_cancel(): dialog.destroy() # Message label msg_frame = ttk.Frame(dialog, padding=10) msg_frame.pack(fill=tk.X) if candidates: msg_text = "Multiple source folders found. Please select the correct one:" else: msg_text = "No source folder found automatically. Please browse to select the folder containing __main__.py:" ttk.Label(msg_frame, text=msg_text, wraplength=560).pack() # Listbox with candidates if candidates: list_frame = ttk.Frame(dialog, padding=10) list_frame.pack(fill=tk.BOTH, expand=True) scrollbar = ttk.Scrollbar(list_frame) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) listbox = tk.Listbox(list_frame, yscrollcommand=scrollbar.set) listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.config(command=listbox.yview) for candidate in candidates: listbox.insert(tk.END, candidate.name) listbox.bind('', lambda e: on_select()) else: listbox = None # Buttons btn_frame = ttk.Frame(dialog, padding=10) btn_frame.pack(fill=tk.X) if candidates: ttk.Button(btn_frame, text="Select", command=on_select).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame, text="Browse...", command=on_browse).pack(side=tk.LEFT, padx=5) ttk.Button(btn_frame, text="Cancel", command=on_cancel).pack(side=tk.RIGHT, padx=5) dialog.wait_window() return selected_folder def select_project_directory(self) -> None: """ Opens a file dialog for the user to select the main project directory. Derives and updates paths for the main script, .spec file, and icon. If the expected source folder is not found, searches for alternatives and asks the user. """ self._log( "=" * 20 + " PROJECT DIRECTORY SELECTION STARTED " + "=" * 20, level="INFO" ) dir_path = filedialog.askdirectory( title="Select Main Project Directory", parent=self.parent_gui ) if not dir_path: self._log("Directory selection cancelled.", level="INFO") return self.project_directory_path.set(dir_path) self._log(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() # Try to find the source directory - first check the expected location expected_source_dir = project_root / project_name_lower source_folder_name = project_name_lower # Default to expected name if (expected_source_dir / "__main__.py").is_file(): # Expected location found - use it self.derived_source_dir_path = expected_source_dir self._log(f"Using expected source folder: {source_folder_name}", level="INFO") else: # Expected location not found - search for alternatives self._log(f"Expected source folder '{project_name_lower}' not found or does not contain __main__.py", level="WARNING") self._log("Searching for alternative source folders...", level="INFO") candidates = self._find_source_folders_with_main(project_root) if len(candidates) == 1: # Only one candidate found - use it automatically self.derived_source_dir_path = candidates[0] source_folder_name = candidates[0].name self._log(f"Automatically selected single candidate: {source_folder_name}", level="INFO") elif len(candidates) > 1: # Multiple candidates - ask user to choose self._log(f"Found {len(candidates)} candidate folders, asking user to select...", level="INFO") selected = self._ask_user_for_source_folder(project_root, candidates) if selected: self.derived_source_dir_path = selected source_folder_name = selected.name self._log(f"User selected source folder: {source_folder_name}", level="INFO") else: self._log("User cancelled source folder selection.", level="INFO") return else: # No candidates found - ask user to browse self._log("No source folders with __main__.py found, asking user to browse...", level="WARNING") selected = self._ask_user_for_source_folder(project_root, []) if selected: self.derived_source_dir_path = selected source_folder_name = selected.name self._log(f"User selected source folder: {source_folder_name}", level="INFO") else: self._log("User cancelled source folder selection.", level="INFO") return # Now derive paths using the determined source folder name self.derived_main_script_path = str( self.derived_source_dir_path / "__main__.py" ) self.derived_spec_path = str(project_root / f"{source_folder_name}.spec") # Default icon filename based on platform default_icon_filename = f"{source_folder_name}.ico" if sys.platform == "darwin": default_icon_filename = f"{source_folder_name}.icns" elif sys.platform != "win32": default_icon_filename = f"{source_folder_name}.png" self.derived_icon_path = str(project_root / default_icon_filename) self._log(f"Derived project name: {self.project_root_name}", level="DEBUG") self._log( f" Expected source path: {self.derived_source_dir_path}", level="DEBUG" ) self._log( f" Expected main script: {self.derived_main_script_path}", level="DEBUG" ) self._log(f" Expected spec file: {self.derived_spec_path}", level="DEBUG") self._log( f" Expected icon file: {self.derived_icon_path} (platform default)", level="DEBUG", ) # Update GUI labels for derived paths (use pathlib for checks) main_script_path_obj = pathlib.Path(self.derived_main_script_path) spec_path_obj = pathlib.Path(self.derived_spec_path) icon_path_obj = pathlib.Path(self.derived_icon_path) # At this point, main script should always exist since we verified it during folder selection if main_script_path_obj.is_file(): self.derived_script_label_val.config( text=str(main_script_path_obj), foreground="black" ) self._log(f"Main script confirmed: {main_script_path_obj}", level="INFO") else: # This shouldn't happen anymore, but keep as safeguard self.derived_script_label_val.config(text="NOT FOUND!", foreground="red") self._log( f"Error: Main script not found: {main_script_path_obj}", level="ERROR", ) messagebox.showerror( "Invalid Project Structure", f"Main script not found:\n{main_script_path_obj}", parent=self.parent_gui, ) return if spec_path_obj.is_file(): self.derived_spec_label_val.config( text=str(spec_path_obj), foreground="black" ) else: self.derived_spec_label_val.config( text="NOT FOUND (will be generated)", foreground="orange" ) if icon_path_obj.is_file(): self.derived_icon_label_val.config( text=str(icon_path_obj), foreground="black" ) self.icon_path_var.set( str(icon_path_obj) ) # Auto-fill icon path if default found else: self.derived_icon_label_val.config( text="Not found (optional)", foreground="grey" ) self.icon_path_var.set("") # Clear icon path if default not found # Source directory should always exist at this point if ( isinstance(self.derived_source_dir_path, pathlib.Path) and not self.derived_source_dir_path.is_dir() ): self._log( f"Warning: Source directory '{self.derived_source_dir_path.name}' does not exist.", level="WARNING", ) # Call the callback - main script existence is guaranteed at this point if self.on_project_selected_callback: # Prepare a dictionary of derived paths to pass to the callback derived_paths_info: Dict[str, Any] = { "project_root_path": dir_path, "project_root_name": self.project_root_name, "main_script_path": self.derived_main_script_path, "derived_source_dir_path": self.derived_source_dir_path, # pathlib.Path object "spec_file_path": self.derived_spec_path, "derived_icon_path": self.derived_icon_path, } self.on_project_selected_callback(dir_path, derived_paths_info) else: self._log( "Project selection failed or main script not found. Callback not invoked.", level="INFO", ) def get_derived_paths(self) -> Dict[str, Optional[Union[str, pathlib.Path]]]: """Returns a dictionary of currently derived paths.""" return { "main_script_path": self.derived_main_script_path, "derived_source_dir_path": self.derived_source_dir_path, "spec_file_path": self.derived_spec_path, "derived_icon_path": self.derived_icon_path, "project_root_name": self.project_root_name, "project_directory_path": self.project_directory_path.get(), }