SXXXXXXX_PyInstallerGUIWrapper/pyinstallerguiwrapper/build/build_orchestrator.py

739 lines
40 KiB
Python

# 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"
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_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]:
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 = 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())