# 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 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" WEXPECT_HELPER_FINAL_NAME_IN_BUNDLE = "wexpect.exe" COMPILED_HELPER_EXE_ON_DISK_NAME = "wexpect_console_helper_temp.exe" 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_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_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 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 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']) 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 _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]: 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 = parsed_spec_opts.get('hiddenimports', []) final_binaries = parsed_spec_opts.get('binaries', []) 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, '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] = [ ast.Expr(value=create_node("\n# PyInstaller spec file generated by GUI Wrapper.\n")), ast.Assign(targets=[ast.Name(id=cipher_var, ctx=ast.Store())], value=create_node(None)), 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([])), ast.keyword(arg='hooksconfig', value=ast.Dict(keys=[], values=[])), ast.keyword(arg='runtime_hooks', value=create_node([])), 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(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() 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())