# pyinstallerguiwrapper/build/build_orchestrator.py # -*- coding: utf-8 -*- """ Manages the orchestration of the PyInstaller build process, including pre-build steps like configuration backup, wexpect helper compilation, spec file generation, and post-build steps like configuration restoration and cleanup. """ import os import sys import pathlib import shutil import subprocess import threading import queue import traceback import datetime import ast import tempfile from typing import Optional, List, Tuple, Any, Dict, Callable, Union # Package imports from pyinstallerguiwrapper import config as app_config from pyinstallerguiwrapper import spec_parser from pyinstallerguiwrapper import dependency_detector from pyinstallerguiwrapper import builder from pyinstallerguiwrapper.gui.ast_transformer import SpecTransformer if sys.version_info < (3, 9): try: import astor # type: ignore except ImportError: pass WEXPECT_HELPER_SOURCE_SCRIPT_NAME = "wexpect_console_entry.py" EXE_EXT = ".exe" if sys.platform == "win32" else "" WEXPECT_HELPER_FINAL_NAME_IN_BUNDLE = f"wexpect{EXE_EXT}" COMPILED_HELPER_EXE_ON_DISK_NAME = f"wexpect_console_helper_temp{EXE_EXT}" class BuildOrchestrator: def __init__(self, parent_gui_ref: Any, output_logger_log_message_func: Callable[[str, str], None], build_queue_ref: queue.Queue, project_manager_ref: Any, version_manager_ref: Any, data_files_manager_ref: Any, get_project_dir_func: Callable[[], str], get_main_script_path_func: Callable[[], Optional[str]], get_derived_source_dir_path_func: Callable[[], Optional[pathlib.Path]], get_spec_file_path_func: Callable[[], Optional[str]], get_project_root_name_func: Callable[[], Optional[str]], get_log_level_func: Callable[[], str], get_clean_output_dir_func: Callable[[], bool], get_app_name_func: Callable[[], str], get_icon_path_func: Callable[[], Optional[str]], get_is_onefile_func: Callable[[], bool], get_is_windowed_func: Callable[[], bool], get_use_upx_func: Callable[[], bool], get_build_wexpect_helper_func: Callable[[], bool], get_auto_include_hiddenimports_func: Callable[[], bool], get_last_parsed_spec_options_func: Callable[[], Optional[Dict[str, Any]]], update_build_button_state_callback: Callable[[str], None], update_derived_spec_label_callback: Callable[[str, str], None] ): self.parent_gui = parent_gui_ref self.logger = output_logger_log_message_func self.build_queue = build_queue_ref self.project_manager = project_manager_ref self.version_manager = version_manager_ref self.data_files_manager = data_files_manager_ref self._get_project_dir = get_project_dir_func self._get_main_script_path = get_main_script_path_func self._get_derived_source_dir_path = get_derived_source_dir_path_func self._get_spec_file_path = get_spec_file_path_func self._get_project_root_name = get_project_root_name_func self._get_log_level = get_log_level_func self._get_clean_output_dir = get_clean_output_dir_func self._get_app_name = get_app_name_func self._get_icon_path = get_icon_path_func self._get_is_onefile = get_is_onefile_func self._get_is_windowed = get_is_windowed_func self._get_use_upx = get_use_upx_func self._get_build_wexpect_helper = get_build_wexpect_helper_func self._get_auto_include_hiddenimports = get_auto_include_hiddenimports_func self._get_last_parsed_spec_options = get_last_parsed_spec_options_func self._update_build_button_state = update_build_button_state_callback self._update_derived_spec_label = update_derived_spec_label_callback self.build_thread: Optional[threading.Thread] = None self._wexpect_helper_compiled_path: Optional[str] = None self._backup_performed: bool = False self._temp_backup_dir_path: Optional[pathlib.Path] = None self._temp_helper_dir_path: Optional[str] = None self._confirmed_hiddenimports: Optional[List[str]] = None def _log_message(self, message: str, level: str = "INFO") -> None: self.logger(message, level) def start_build_process(self) -> None: self._log_message("="*20 + " BUILD PROCESS ORCHESTRATION STARTED " + "="*20, level="INFO") project_dir_str = self._get_project_dir() if not project_dir_str or not os.path.isdir(project_dir_str): self._show_message_box("Error", "Project directory is not selected or invalid.") self._log_message("Build cancelled: invalid project directory.", level="ERROR") return main_script_path = self._get_main_script_path() main_script_path_obj = pathlib.Path(main_script_path) if main_script_path else None if not main_script_path_obj or not main_script_path_obj.is_file(): self._show_message_box("Error", f"Main script '{main_script_path_obj or 'Not Set'}' not found. Cannot proceed.") self._log_message("Build cancelled: main script missing or path not set.", level="ERROR") return version_file_placement_dir = main_script_path_obj.parent if not version_file_placement_dir.is_dir(): self._show_message_box("Error", f"Directory for version file '{version_file_placement_dir}' does not exist. Cannot proceed.") self._log_message("Build cancelled: target directory for _version.py missing.", level="ERROR") return self._backup_performed = False self._temp_backup_dir_path = None self._temp_helper_dir_path = None if not self._perform_config_backup(): pass if self._get_build_wexpect_helper(): self._log_message("Build wexpect helper option is enabled.", level="INFO") compiled_helper_path_result = self._build_wexpect_helper_executable() if compiled_helper_path_result: self._wexpect_helper_compiled_path = compiled_helper_path_result self._log_message(f"Wexpect console helper successfully built at: {self._wexpect_helper_compiled_path}", level="INFO") else: self._log_message("Wexpect console helper build failed or was skipped. Main build will continue without it.", level="WARNING") else: self._wexpect_helper_compiled_path = None # Continue build orchestration: generate version file, spec and run PyInstaller target_version_file = version_file_placement_dir / "_version.py" self._log_message(f"Target _version.py will be generated in: {version_file_placement_dir}", level="DEBUG") if not self.version_manager.generate_target_version_file(project_dir_str, str(target_version_file)): self._log_message("Build cancelled: Failed to generate target's _version.py file.", level="ERROR") self._cleanup_backup_dir_if_exists() return self._log_message("Generating .spec file content for main application...", level="INFO") spec_content_str = self._generate_spec_file_content() if spec_content_str is None: self._log_message("Build cancelled: .spec file content generation failed.", level="ERROR") self._cleanup_backup_dir_if_exists() self._on_build_attempt_finished() return if not self._save_spec_file_to_disk(spec_content_str): self._log_message("Build cancelled: Failed to save the .spec file.", level="ERROR") self._cleanup_backup_dir_if_exists() self._on_build_attempt_finished() return self._log_message("Preparing PyInstaller execution...", level="INFO") self._update_build_button_state("disabled") dist_path_abs_str = os.path.join(project_dir_str, app_config.DEFAULT_SPEC_OPTIONS['output_dir_name']) work_path_abs_str = os.path.join(project_dir_str, app_config.DEFAULT_SPEC_OPTIONS['work_dir_name']) # Note: previously we attempted to automatically remove/rename the existing # dist directory if locked. That logic was reverted to avoid unexpected # side-effects; the user is expected to ensure the output directory is # writable (close terminals, remove locks) before starting a build. spec_file_for_command = self._get_spec_file_path() if not spec_file_for_command: self._log_message("Build cancelled: Spec file path for command is not available.", level="CRITICAL") self._show_message_box("Error", "Internal error: Spec file path not determined for PyInstaller command.") self._on_build_attempt_finished() self._cleanup_backup_dir_if_exists() return pyinstaller_exec = shutil.which("pyinstaller") if not pyinstaller_exec: self._log_message("'pyinstaller' command not found in system PATH.", level="CRITICAL") self._show_message_box("PyInstaller Not Found", "PyInstaller executable not found in PATH.") self._on_build_attempt_finished() self._cleanup_backup_dir_if_exists() return command_list = [ pyinstaller_exec, spec_file_for_command, "--distpath", dist_path_abs_str, "--workpath", work_path_abs_str, "--log-level", self._get_log_level().upper() ] if self._get_clean_output_dir(): command_list.append("--clean") self._log_message("PyInstaller --clean flag will be used.", level="INFO") if not app_config.DEFAULT_SPEC_OPTIONS['confirm_overwrite']: command_list.append("--noconfirm") build_env = os.environ.copy() build_env.pop("TCL_LIBRARY", None) build_env.pop("TK_LIBRARY", None) self._log_message("Removed ['TCL_LIBRARY', 'TK_LIBRARY'] from PyInstaller environment.", level="DEBUG") self.build_thread = threading.Thread( target=builder.run_build_in_thread, args=( command_list, project_dir_str, self.build_queue, self.logger, app_config.DEFAULT_SPEC_OPTIONS['output_dir_name'], build_env ), daemon=True ) self.build_thread.start() self._log_message("PyInstaller build process started in a background thread.", level="INFO") def set_confirmed_hiddenimports(self, hiddenimports: Optional[List[str]]) -> None: """Set a user-confirmed list of hiddenimports to be used for the next build. The GUI should call this when the user has reviewed/edited detected modules. Passing None clears any previously set confirmation. """ # Only store the user-confirmed list here. Actual build steps run in # `start_build_process` and will pick up this list when generating the .spec. if hiddenimports is None: self._confirmed_hiddenimports = None else: try: self._confirmed_hiddenimports = [str(x).strip() for x in hiddenimports if x] except Exception: self._confirmed_hiddenimports = None self._log_message(f"Confirmed hiddenimports set: {self._confirmed_hiddenimports}", level="DEBUG") # The automatic cleanup/rename helper was removed to avoid unexpected # filesystem operations when folders are in use by the user (terminal, # Explorer, antivirus). Users should manually ensure the output directory is # writable or delete/rename it before running a build. def _perform_config_backup(self) -> bool: project_dir_str = self._get_project_dir() dist_path_abs = pathlib.Path(project_dir_str) / app_config.DEFAULT_SPEC_OPTIONS['output_dir_name'] if self._get_clean_output_dir() or not dist_path_abs.is_dir(): log_reason = "'Clean output directory' is ON." if self._get_clean_output_dir() else f"Output directory '{dist_path_abs}' does not exist." self._log_message(f"Skipping config backup: {log_reason}", level="INFO") return True self._log_message(f"Config backup: Checking for .json/.ini files in '{dist_path_abs}'.", level="INFO") self._temp_backup_dir_path = pathlib.Path(project_dir_str) / f"_dist_config_backup_temp_{datetime.datetime.now().strftime('%Y%m%d%H%M%S%f')}" try: if self._temp_backup_dir_path.exists(): shutil.rmtree(self._temp_backup_dir_path) self._temp_backup_dir_path.mkdir(parents=True, exist_ok=True) self._log_message(f"Created temporary backup directory: {self._temp_backup_dir_path}", level="DEBUG") files_to_backup = [fp for fp in dist_path_abs.rglob('*') if fp.is_file() and fp.suffix.lower() in ['.json', '.ini']] if files_to_backup: self._log_message(f"Found {len(files_to_backup)} config file(s) to backup.", level="INFO") for src_file_path in files_to_backup: relative_path = src_file_path.relative_to(dist_path_abs) dest_in_backup = self._temp_backup_dir_path / relative_path dest_in_backup.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src_file_path, dest_in_backup) self._log_message(f" Backed up: {src_file_path.name} -> {dest_in_backup}", level="DEBUG") self._backup_performed = True else: self._log_message("No .json or .ini files found in existing output directory to backup.", level="INFO") shutil.rmtree(self._temp_backup_dir_path) self._temp_backup_dir_path = None return True except Exception as e_bkp: self._log_message(f"Error during config file backup: {e_bkp}", level="ERROR") self._log_message(traceback.format_exc(), level="DEBUG") self._show_message_box("Backup Warning", f"Failed to backup configuration files: {e_bkp}\nBuild will continue, but existing configs in output might be lost or not restored.") self._backup_performed = False if self._temp_backup_dir_path and self._temp_backup_dir_path.exists(): shutil.rmtree(self._temp_backup_dir_path) self._temp_backup_dir_path = None return False def _build_wexpect_helper_executable(self) -> Optional[str]: if sys.platform != "win32": self._log_message("Skipping wexpect helper build: 'wexpect' is strictly for Windows.", level="WARNING") return None self._log_message("="*10 + " BUILDING WEXPECT CONSOLE HELPER " + "="*10, level="INFO") self._wexpect_helper_compiled_path = None self._temp_helper_dir_path = None project_root_path_str = self._get_project_dir() helper_script_abs_path: Optional[pathlib.Path] = None try: import pyinstallerguiwrapper.helpers helper_package_dir = pathlib.Path(pyinstallerguiwrapper.helpers.__file__).parent helper_script_abs_path = helper_package_dir / WEXPECT_HELPER_SOURCE_SCRIPT_NAME if not helper_script_abs_path.is_file(): fallback_path = pathlib.Path(__file__).parent.parent / "helpers" / WEXPECT_HELPER_SOURCE_SCRIPT_NAME if fallback_path.resolve().is_file(): helper_script_abs_path = fallback_path.resolve() self._log_message(f"Found helper script via fallback path: {helper_script_abs_path}", level="INFO") else: self._log_message(f"Wexpect helper source script not found at primary or fallback locations.", level="CRITICAL") helper_script_abs_path = None except Exception as e_path: self._log_message(f"Error determining helper script path: {e_path}\n{traceback.format_exc()}", level="CRITICAL") helper_script_abs_path = None if not helper_script_abs_path or not helper_script_abs_path.is_file(): self._show_message_box("Helper Build Error", f"Source script '{WEXPECT_HELPER_SOURCE_SCRIPT_NAME}' for wexpect helper not found.") return None self._log_message(f"Using wexpect helper source script: {helper_script_abs_path}", level="INFO") try: self._temp_helper_dir_path = tempfile.mkdtemp(prefix="pyinstaller_wexpect_helper_") self._log_message(f"Created system temporary directory for helper build: {self._temp_helper_dir_path}", "DEBUG") temp_dir_path_obj = pathlib.Path(self._temp_helper_dir_path) helper_spec_file = temp_dir_path_obj / "wexpect_helper.spec" helper_dist_output_dir = temp_dir_path_obj / "dist" helper_build_temp_dir = temp_dir_path_obj / "build" except Exception as e_mkdir_helper: self._log_message(f"Failed to create system temporary directory for helper build: {e_mkdir_helper}", level="CRITICAL") self._cleanup_helper_temp_dir() return None helper_spec_str_content = f""" # -*- coding: utf-8 -*- # Autogenerated .spec file for the wexpect console helper. a = Analysis( scripts=['{str(helper_script_abs_path).replace('\\', '\\\\')}'], pathex=[], binaries=[], datas=[], hiddenimports=['wexpect', 'psutil', 'pywintypes', 'pythoncom', 'win32api', 'win32con', 'win32event', 'win32file', 'win32gui', 'win32process', 'win32console', 'win32pipe', 'win32security', 'logging', 'time', 'traceback', 'os', 'sys', 'signal', 'socket', 'shutil', 're', 'types', 'queue', 'encodings.utf_8', 'encodings.latin_1', 'encodings.cp437'], hookspath=[], runtime_hooks=[], excludes=[], cipher=None ) pyz = PYZ(a.pure, a.zipped_data, cipher=None) exe = EXE( pyz, a.scripts, name='{COMPILED_HELPER_EXE_ON_DISK_NAME.split('.')[0]}', debug=False, strip=False, upx=False, console=False, windowed=True, onefile=True, icon=None ) """ try: with open(helper_spec_file, "w", encoding="utf-8") as f: f.write(helper_spec_str_content) self._log_message(f"Generated temporary .spec for wexpect_helper: {helper_spec_file}", level="DEBUG") except Exception as e_write_spec: self._log_message(f"Failed to write .spec file for wexpect_helper: {e_write_spec}", level="CRITICAL") self._show_message_box("Helper Build Error", f"Could not write .spec file for wexpect_helper: {e_write_spec}") self._cleanup_helper_temp_dir() return None pyinstaller_exec = shutil.which("pyinstaller") if not pyinstaller_exec: self._log_message("'pyinstaller' not found.", level="CRITICAL") self._cleanup_helper_temp_dir() return None helper_build_cmd = [ pyinstaller_exec, str(helper_spec_file), "--distpath", str(helper_dist_output_dir), "--workpath", str(helper_build_temp_dir), "--log-level", self._get_log_level().upper(), "--noconfirm" ] self._log_message(f"Building '{COMPILED_HELPER_EXE_ON_DISK_NAME}' using: {' '.join(helper_build_cmd)}", level="INFO") self._log_message(f" Helper build CWD: {project_root_path_str}", level="DEBUG") build_successful = False final_helper_path_str = None try: build_env = os.environ.copy() build_env.pop("TCL_LIBRARY", None) build_env.pop("TK_LIBRARY", None) process = subprocess.Popen( helper_build_cmd, cwd=project_root_path_str, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding='utf-8', errors='replace', bufsize=1, env=build_env ) self._log_message(f"--- Wexpect Helper ('{COMPILED_HELPER_EXE_ON_DISK_NAME}') PyInstaller Output ---", level="INFO") if process.stdout: for line in iter(process.stdout.readline, ''): if line: self._log_message(f"[HelperBuild] {line.strip()}", level="DEBUG") return_code = process.wait() if process.stdout: process.stdout.close() self._log_message(f"--- End Wexpect Helper PyInstaller Output (RC: {return_code}) ---", level="INFO") if return_code == 0: compiled_helper_path_temp = helper_dist_output_dir / COMPILED_HELPER_EXE_ON_DISK_NAME if compiled_helper_path_temp.is_file(): final_helper_path_renamed = compiled_helper_path_temp.parent / WEXPECT_HELPER_FINAL_NAME_IN_BUNDLE try: if final_helper_path_renamed.exists(): final_helper_path_renamed.unlink() compiled_helper_path_temp.rename(final_helper_path_renamed) self._log_message(f"Wexpect helper build successful and renamed to: {final_helper_path_renamed}", level="INFO") build_successful = True final_helper_path_str = str(final_helper_path_renamed) except Exception as e_rename: self._log_message(f"Helper build successful, but failed to rename helper executable: {e_rename}", level="CRITICAL") else: self._log_message(f"Helper build OK, but '{COMPILED_HELPER_EXE_ON_DISK_NAME}' not in {helper_dist_output_dir}", level="ERROR") else: self._log_message(f"Wexpect helper build FAILED. Exit code: {return_code}.", level="ERROR") except Exception as e_build_helper: self._log_message(f"Exception during wexpect_helper build: {e_build_helper}\n{traceback.format_exc()}", level="CRITICAL") if not build_successful: self._show_message_box("Helper Build Failed", f"Failed to build '{COMPILED_HELPER_EXE_ON_DISK_NAME}'. Main build proceeds without it.") self._cleanup_helper_temp_dir() return None return final_helper_path_str def _collect_gui_options_for_spec_generation(self) -> Dict[str, Any]: project_dir_str = self._get_project_dir() project_root_path = pathlib.Path(project_dir_str).resolve() if project_dir_str else pathlib.Path(".").resolve() main_script_path = self._get_main_script_path() derived_source_dir_path = self._get_derived_source_dir_path() script_rel_path: Optional[str] = None if main_script_path and pathlib.Path(main_script_path).is_file(): try: script_rel_path = os.path.relpath(str(pathlib.Path(main_script_path).resolve()), str(project_root_path)) except ValueError: self._log_message(f"Cannot make main script path '{main_script_path}' relative. Using absolute.", level="WARNING") script_rel_path = str(pathlib.Path(main_script_path).resolve()) source_dir_rel_path: str = "." if derived_source_dir_path and isinstance(derived_source_dir_path, pathlib.Path): try: if derived_source_dir_path.is_dir(): source_dir_rel_path = os.path.relpath(str(derived_source_dir_path.resolve()), str(project_root_path)) elif derived_source_dir_path == project_root_path: source_dir_rel_path = "." except ValueError: self._log_message(f"Cannot make source dir '{derived_source_dir_path}' relative. Using absolute.", level="WARNING") source_dir_rel_path = str(derived_source_dir_path.resolve()) icon_gui_path_str = self._get_icon_path() icon_rel_path_for_spec: Optional[str] = None if icon_gui_path_str and pathlib.Path(icon_gui_path_str).is_file(): try: icon_rel_path_for_spec = os.path.relpath(str(pathlib.Path(icon_gui_path_str).resolve()), str(project_root_path)) except ValueError: self._log_message(f"Cannot make icon path '{icon_gui_path_str}' relative. Using absolute.", level="WARNING") icon_rel_path_for_spec = str(pathlib.Path(icon_gui_path_str).resolve()) current_datas_for_spec: List[Tuple[str, str]] = self.data_files_manager.get_current_datas() if self._get_build_wexpect_helper() and self._wexpect_helper_compiled_path and pathlib.Path(self._wexpect_helper_compiled_path).is_file(): helper_src_for_datas = self._wexpect_helper_compiled_path destination_folder = "wexpect_resources" #"." self._log_message("Filtering stale 'wexpect.exe' entries from datas list.", level="DEBUG") filtered_datas = [ (src, dest) for src, dest in current_datas_for_spec if os.path.basename(src) != WEXPECT_HELPER_FINAL_NAME_IN_BUNDLE ] if len(filtered_datas) < len(current_datas_for_spec): self._log_message(f" Removed {len(current_datas_for_spec) - len(filtered_datas)} stale wexpect entry/entries.", level="DEBUG") current_datas_for_spec = filtered_datas current_datas_for_spec.append((helper_src_for_datas, destination_folder)) self._log_message(f"Added current wexpect helper to 'datas': '{helper_src_for_datas}' -> '{destination_folder}'", level="INFO") parsed_spec_opts = self._get_last_parsed_spec_options() or {} final_hiddenimports = list(parsed_spec_opts.get('hiddenimports', []) or []) # Preserve hookspath and runtime_hooks from existing spec if present final_hookspath = parsed_spec_opts.get('hookspath', []) or [] final_runtime_hooks = parsed_spec_opts.get('runtime_hooks', []) or [] # Use a user-confirmed list if provided by the GUI; otherwise, optionally # run the static detector when the option is enabled. try: if self._confirmed_hiddenimports is not None: for d in self._confirmed_hiddenimports: if d not in final_hiddenimports: final_hiddenimports.append(d) self._log_message(f"Using {len(self._confirmed_hiddenimports)} user-confirmed hiddenimports.", level="INFO") elif hasattr(self, '_get_auto_include_hiddenimports') and self._get_auto_include_hiddenimports(): detected = dependency_detector.detect_external_dependencies(project_dir_str, logger=self._log_message) if detected: for d in detected: if d not in final_hiddenimports: final_hiddenimports.append(d) self._log_message(f"Appended {len(detected)} detected external modules to hiddenimports.", level="INFO") else: self._log_message("Dependency detector ran but found no external modules.", level="DEBUG") else: self._log_message("Auto-include detected hiddenimports disabled or not set; skipping dependency detector.", level="DEBUG") except Exception as e: self._log_message(f"Dependency detection failed: {e}", level="WARNING") final_binaries = parsed_spec_opts.get('binaries', []) # Detect packages that were packaged as wheels/datas which may need collect_all collect_all_pkgs: List[str] = [] try: datas_from_spec = parsed_spec_opts.get('datas', []) or [] for d in datas_from_spec: # datas entries may be tuples like (src, dest) try: src = d[0] if isinstance(d, (list, tuple)) and len(d) > 0 else str(d) except Exception: src = str(d) src_lower = str(src).lower() if 'pygount' in src_lower and 'pygount' not in collect_all_pkgs: collect_all_pkgs.append('pygount') except Exception: pass analysis_pathex_list = [] if source_dir_rel_path and source_dir_rel_path != ".": analysis_pathex_list.append(source_dir_rel_path) analysis_pathex_list.append(".") return { 'analysis_scripts': [script_rel_path] if script_rel_path else [], 'analysis_pathex': analysis_pathex_list, 'formatted_datas': current_datas_for_spec, 'formatted_hiddenimports': final_hiddenimports, 'formatted_binaries': final_binaries, 'formatted_hookspath': final_hookspath, 'formatted_runtime_hooks': final_runtime_hooks, 'collect_all_pkgs': collect_all_pkgs, 'app_name': self._get_app_name(), 'icon_rel_path': icon_rel_path_for_spec, 'is_windowed': self._get_is_windowed(), 'is_onefile': self._get_is_onefile(), 'use_upx': self._get_use_upx(), 'pyz_cipher_var_name': 'block_cipher', 'a_var_name': 'a', 'pyz_var_name': 'pyz', 'exe_var_name': 'exe' } def _generate_spec_file_content(self) -> Optional[str]: gui_options = self._collect_gui_options_for_spec_generation() if not gui_options['analysis_scripts'] or not gui_options['analysis_scripts'][0]: self._show_message_box("Error", "Main script path undetermined. Cannot generate .spec.") self._log_message("Spec content generation failed: Main script path missing.", level="ERROR") return None spec_file_path_str = self._get_spec_file_path() spec_file_path_obj = pathlib.Path(spec_file_path_str) if spec_file_path_str else None if spec_file_path_obj and spec_file_path_obj.is_file(): self._log_message(f"Modifying existing spec file: {spec_file_path_obj}.", level="INFO") try: with open(spec_file_path_obj, 'r', encoding='utf-8') as f: source_code = f.read() original_tree = ast.parse(source_code, filename=str(spec_file_path_obj)) transformer = SpecTransformer(gui_options, logger_func=self.logger) modified_tree = transformer.visit(original_tree) ast.fix_missing_locations(modified_tree) if sys.version_info >= (3, 9): new_spec_content = ast.unparse(modified_tree) else: if 'astor' not in sys.modules: raise ImportError("Python < 3.9 requires 'astor'. `pip install astor`") new_spec_content = astor.to_source(modified_tree) self._log_message("Successfully modified existing .spec file content.", level="INFO") return new_spec_content.strip() + "\n" except ImportError as e_imp: self._log_message(f"CRITICAL: {e_imp}. Cannot update .spec file.", level="CRITICAL") self._show_message_box("Dependency Missing", str(e_imp)) return None except Exception as e: self._log_message(f"Error transforming spec '{spec_file_path_obj}': {e}\n{traceback.format_exc()}", level="CRITICAL") self._show_message_box("Spec Mod Error", f"Could not modify existing .spec: {e}\nAttempting new. Check logs.") return self._build_new_spec_ast_content(gui_options) else: self._log_message("No existing .spec file. Generating new .spec content.", level="INFO") return self._build_new_spec_ast_content(gui_options) def _build_new_spec_ast_content(self, gui_options_dict: Dict[str, Any]) -> Optional[str]: self._log_message("Building .spec AST from scratch.", level="INFO") try: def create_node(value: Any) -> ast.expr: return SpecTransformer._create_ast_node_static(value, self.logger) def name_attr(base_var: str, attr: str) -> ast.Attribute: return ast.Attribute(value=ast.Name(id=base_var, ctx=ast.Load()), attr=attr, ctx=ast.Load()) cipher_var, analysis_var, pyz_var = gui_options_dict['pyz_cipher_var_name'], gui_options_dict['a_var_name'], gui_options_dict['pyz_var_name'] nodes: List[ast.stmt] = [] # Optional: support injecting collect_all(...) assignments for packages collect_all_pkgs = gui_options_dict.get('collect_all_pkgs', []) or [] if collect_all_pkgs: # Add: from PyInstaller.utils.hooks import collect_all try: import_node = ast.ImportFrom(module='PyInstaller.utils.hooks', names=[ast.alias(name='collect_all', asname=None)], level=0) nodes.append(import_node) except Exception: pass for pkg in collect_all_pkgs: # Create names like _pygount_datas, _pygount_binaries, _pygount_hiddenimports safe_pkg = pkg.replace('-', '_') names_tuple = ast.Tuple(elts=[ ast.Name(id=f'_{safe_pkg}_datas', ctx=ast.Store()), ast.Name(id=f'_{safe_pkg}_binaries', ctx=ast.Store()), ast.Name(id=f'_{safe_pkg}_hiddenimports', ctx=ast.Store()) ], ctx=ast.Store()) call_node = ast.Call(func=ast.Name(id='collect_all', ctx=ast.Load()), args=[ast.Constant(value=pkg)], keywords=[]) assign_node = ast.Assign(targets=[names_tuple], value=call_node) nodes.append(assign_node) # Standard leading comment and cipher assignment nodes.append(ast.Expr(value=create_node("\n# PyInstaller spec file generated by GUI Wrapper.\n"))) nodes.append(ast.Assign(targets=[ast.Name(id=cipher_var, ctx=ast.Store())], value=create_node(None))) # Build Analysis node analysis_node = ast.Assign( targets=[ast.Name(id=analysis_var, ctx=ast.Store())], value=ast.Call( func=ast.Name(id='Analysis', ctx=ast.Load()), args=[ create_node(gui_options_dict.get('analysis_scripts', [])), create_node(gui_options_dict.get('analysis_pathex', ["."])) ], keywords=[ ast.keyword(arg='binaries', value=create_node(gui_options_dict.get('formatted_binaries',[]))), ast.keyword(arg='datas', value=create_node(gui_options_dict.get('formatted_datas', []))), ast.keyword(arg='hiddenimports', value=create_node(gui_options_dict.get('formatted_hiddenimports',[]))), ast.keyword(arg='hookspath', value=create_node(gui_options_dict.get('formatted_hookspath', []))), ast.keyword(arg='hooksconfig', value=ast.Dict(keys=[], values=[])), ast.keyword(arg='runtime_hooks', value=create_node(gui_options_dict.get('formatted_runtime_hooks', []))), ast.keyword(arg='excludes', value=create_node([])), ast.keyword(arg='win_no_prefer_redirects', value=create_node(False)), ast.keyword(arg='win_private_assemblies', value=create_node(False)), ast.keyword(arg='cipher', value=ast.Name(id=cipher_var, ctx=ast.Load())), ast.keyword(arg='noarchive', value=create_node(False)) ])) nodes.append(analysis_node) # If we injected collect_all assignments, append try/except blocks to add hiddenimports to 'a' if collect_all_pkgs: for pkg in collect_all_pkgs: safe_pkg = pkg.replace('-', '_') aug = ast.AugAssign(target=ast.Attribute(value=ast.Name(id='a', ctx=ast.Load()), attr='hiddenimports', ctx=ast.Store()), op=ast.Add(), value=ast.Name(id=f'_{safe_pkg}_hiddenimports', ctx=ast.Load())) try_node = ast.Try(body=[aug], handlers=[ast.ExceptHandler(type=None, name=None, body=[ast.Pass()])], orelse=[], finalbody=[]) nodes.append(try_node) nodes.append(ast.Assign( targets=[ast.Name(id=pyz_var, ctx=ast.Store())], value=ast.Call( func=ast.Name(id='PYZ', ctx=ast.Load()), args=[name_attr(analysis_var, 'pure'), name_attr(analysis_var, 'zipped_data')], keywords=[ast.keyword(arg='cipher', value=ast.Name(id=cipher_var, ctx=ast.Load()))] ))) exe_keywords = [ ast.keyword(arg='name', value=create_node(gui_options_dict.get('app_name', 'default_app'))), ast.keyword(arg='debug', value=create_node(False)), ast.keyword(arg='bootloader_ignore_signals', value=create_node(False)), ast.keyword(arg='strip', value=create_node(False)), ast.keyword(arg='upx', value=create_node(gui_options_dict.get('use_upx', True))), ast.keyword(arg='runtime_tmpdir', value=create_node(None)), ast.keyword(arg='console', value=create_node(not gui_options_dict.get('is_windowed', True))), ast.keyword(arg='disable_windowed_traceback', value=create_node(False)), ast.keyword(arg='target_arch', value=create_node(None)), ast.keyword(arg='codesign_identity', value=create_node(None)), ast.keyword(arg='entitlements_file', value=create_node(None)) ] if gui_options_dict.get('icon_rel_path'): exe_keywords.append(ast.keyword(arg='icon', value=create_node(gui_options_dict['icon_rel_path']))) exe_args = [ast.Name(id=pyz_var, ctx=ast.Load()), name_attr(analysis_var, 'scripts')] if gui_options_dict.get('is_onefile', False): exe_args.extend([ name_attr(analysis_var, 'binaries'), name_attr(analysis_var, 'datas') ]) else: exe_keywords.append(ast.keyword(arg='exclude_binaries', value=create_node(True))) nodes.append(ast.Assign(targets=[ast.Name(id='exe', ctx=ast.Store())], value=ast.Call(func=ast.Name(id='EXE', ctx=ast.Load()), args=exe_args, keywords=exe_keywords))) if not gui_options_dict.get('is_onefile', False): nodes.append(ast.Assign( targets=[ast.Name(id='coll', ctx=ast.Store())], value=ast.Call( func=ast.Name(id='COLLECT', ctx=ast.Load()), args=[ ast.Name(id='exe', ctx=ast.Load()), name_attr(analysis_var, 'binaries'), name_attr(analysis_var, 'zipfiles'), name_attr(analysis_var, 'datas') ], keywords=[ ast.keyword(arg='strip', value=create_node(False)), ast.keyword(arg='upx', value=create_node(gui_options_dict.get('use_upx', True))), ast.keyword(arg='upx_exclude', value=create_node([])), ast.keyword(arg='name', value=create_node(gui_options_dict.get('app_name', 'default_app'))) ]))) module_node = ast.Module(body=nodes, type_ignores=[]) ast.fix_missing_locations(module_node) unparsed_spec_body: str if sys.version_info >= (3, 9): unparsed_spec_body = ast.unparse(module_node) else: if 'astor' not in sys.modules: raise ImportError("Python < 3.9 requires 'astor'. `pip install astor`") unparsed_spec_body = astor.to_source(module_node) final_spec_content = "# -*- mode: python ; coding: utf-8 -*-\n" + unparsed_spec_body.strip() + "\n" return final_spec_content except ImportError as e_imp: self._log_message(f"CRITICAL: ImportError during new spec AST generation: {e_imp}. Cannot generate .spec.", level="CRITICAL") self._show_message_box("Dependency Missing", str(e_imp)) return None except Exception as e_ast_build: self._log_message(f"CRITICAL: Error during new spec AST generation: {e_ast_build}\n{traceback.format_exc()}", level="CRITICAL") self._show_message_box("Spec Gen Error", f"Failed to generate new .spec content: {e_ast_build}") return None def _save_spec_file_to_disk(self, content_str: str) -> bool: if not content_str: self._log_message("Spec content empty, skipping save.", "WARNING") return False spec_file_path_str = self._get_spec_file_path() if not spec_file_path_str: self._show_message_box("Error", "Spec file path is not set. Cannot save.") return False self._log_message(f"Attempting to save .spec file to: {spec_file_path_str}", level="INFO") try: pathlib.Path(spec_file_path_str).parent.mkdir(parents=True, exist_ok=True) with open(spec_file_path_str, 'w', encoding='utf-8') as f: f.write(content_str) self._log_message(f".spec file saved successfully: {spec_file_path_str}", level="INFO") self._update_derived_spec_label(spec_file_path_str, "black") return True except Exception as e: error_msg = f"Failed to save .spec file '{spec_file_path_str}': {e}" self._log_message(error_msg, level="ERROR") self._show_message_box("File Save Error", error_msg) return False def handle_config_restore_after_build(self) -> None: if not self._backup_performed or not self._temp_backup_dir_path or not self._temp_backup_dir_path.exists(): if self._backup_performed: self._log_message("Config restore: Backup expected but dir invalid.", level="WARNING") self._cleanup_backup_dir_if_exists() return self._log_message("Attempting to restore backed-up config files...", level="INFO") dist_path_abs = pathlib.Path(self._get_project_dir()) / app_config.DEFAULT_SPEC_OPTIONS['output_dir_name'] if not dist_path_abs.is_dir(): self._log_message(f"Output dir '{dist_path_abs}' not found. Cannot restore.", level="WARNING") self._cleanup_backup_dir_if_exists() return restored_count = 0 try: for src_file_in_backup in self._temp_backup_dir_path.rglob('*'): if src_file_in_backup.is_file(): rel_path = src_file_in_backup.relative_to(self._temp_backup_dir_path) dest_in_dist = dist_path_abs / rel_path dest_in_dist.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src_file_in_backup, dest_in_dist) self._log_message(f" Restored: {src_file_in_backup.name} -> {dest_in_dist}", level="DEBUG") restored_count += 1 if restored_count > 0: self._log_message(f"Successfully restored {restored_count} config file(s).", level="INFO") else: self._log_message("No files found in backup to restore.", level="INFO") except Exception as e: self._log_message(f"Error during config restoration: {e}\n{traceback.format_exc()}", level="ERROR") self._show_message_box("Config Restore Error", f"Failed to restore config files: {e}\nCheck log.") finally: self._cleanup_backup_dir_if_exists() def _cleanup_backup_dir_if_exists(self) -> None: if self._temp_backup_dir_path and self._temp_backup_dir_path.exists(): try: shutil.rmtree(self._temp_backup_dir_path) self._log_message(f"Cleaned up temp backup dir: {self._temp_backup_dir_path}", level="DEBUG") except Exception as e: self._log_message(f"Error cleaning backup dir {self._temp_backup_dir_path}: {e}", level="WARNING") self._temp_backup_dir_path = None self._backup_performed = False def _cleanup_helper_temp_dir(self) -> None: """Safely removes the temporary directory used for the helper build.""" if self._temp_helper_dir_path and os.path.exists(self._temp_helper_dir_path): try: shutil.rmtree(self._temp_helper_dir_path) self._log_message(f"Cleaned up temp helper build dir: {self._temp_helper_dir_path}", level="DEBUG") except Exception as e: self._log_message(f"Error cleaning helper temp dir {self._temp_helper_dir_path}: {e}", level="WARNING") self._temp_helper_dir_path = None def on_build_attempt_finished(self) -> None: """Handles post-build cleanup and GUI state reset.""" self._cleanup_backup_dir_if_exists() self._cleanup_helper_temp_dir() # Clear any per-build confirmed hiddenimports so next build starts fresh. try: self._confirmed_hiddenimports = None except Exception: pass project_dir_str = self._get_project_dir() main_script_path = self._get_main_script_path() main_script_path_obj = pathlib.Path(main_script_path) if main_script_path else None can_build_again = (project_dir_str and os.path.isdir(project_dir_str) and main_script_path_obj and main_script_path_obj.is_file()) self._update_build_button_state("normal" if can_build_again else "disabled") if self.build_thread and self.build_thread.is_alive(): self._log_message("Build thread still alive, attempting to join...", "WARNING") self.build_thread.join(timeout=1.0) if self.build_thread.is_alive(): self._log_message("Build thread did not terminate cleanly.", "ERROR") self.build_thread = None self._log_message("Build process resources released by orchestrator.", level="INFO") def _show_message_box(self, title: str, message: str, msg_type: str = "error") -> None: try: from tkinter import messagebox if msg_type == "error": messagebox.showerror(title, message, parent=self.parent_gui) elif msg_type == "warning": messagebox.showwarning(title, message, parent=self.parent_gui) elif msg_type == "info": messagebox.showinfo(title, message, parent=self.parent_gui) except Exception as e: self._log_message(f"Failed to show messagebox ('{title}'): {e}", level="ERROR") self._log_message(f"Messagebox Content ({title}): {message}", level="INFO" if msg_type=="info" else msg_type.upper())