739 lines
40 KiB
Python
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()) |