SXXXXXXX_PyInstallerGUIWrapper/pyinstallerguiwrapper/gui/ast_transformer.py

220 lines
12 KiB
Python

# File: pyinstallerguiwrapper/gui/ast_transformer.py
# -*- coding: utf-8 -*-
"""
AST Transformer for modifying PyInstaller .spec files.
"""
import ast
import sys
from typing import Any, Dict, Optional, Callable, Set, List
if sys.version_info < (3, 9):
try:
import astor
except ImportError:
pass
def _default_logger(message, level="DEBUG"):
pass
class SpecTransformer(ast.NodeTransformer):
"""
Transforms an existing .spec file AST to reflect GUI options.
It aims to modify only the arguments of Analysis, EXE, and COLLECT calls
that are managed by the GUI, preserving other parts of the spec file.
"""
def __init__(self, gui_options: Dict[str, Any], logger_func: Optional[Callable[..., None]] = None):
super().__init__()
self.gui_options = gui_options
self.logger = logger_func if logger_func else _default_logger
self.managed_pyinstaller_args: Dict[str, Set[str]] = {
'Analysis': {'scripts', 'pathex', 'datas', 'hiddenimports', 'binaries', 'hookspath', 'runtime_hooks', 'excludes', 'cipher'},
'EXE': {'name', 'debug', 'bootloader_ignore_signals', 'strip', 'upx', 'runtime_tmpdir', 'console', 'icon', 'disable_windowed_traceback', 'target_arch', 'codesign_identity', 'entitlements_file', 'exclude_binaries'},
'COLLECT': {'name', 'strip', 'upx', 'upx_exclude'},
'PYZ': {'cipher'}
}
self.a_var_name = self.gui_options.get('a_var_name', 'a')
self.pyz_var_name = self.gui_options.get('pyz_var_name', 'pyz')
self.exe_var_name = self.gui_options.get('exe_var_name', 'exe')
def _log(self, message: str, level: str = "DEBUG") -> None:
try:
self.logger(f"[SpecTransformer] {message}", level=level)
except TypeError:
self.logger(f"[{level}][SpecTransformer] {message}")
@staticmethod
def _create_ast_node_static(value_to_convert: Any, logger_for_node_creation: Optional[Callable[..., None]] = None) -> ast.expr:
effective_logger = logger_for_node_creation if logger_for_node_creation else _default_logger
def _log_node(msg, lvl="DEBUG"):
try: effective_logger(f"[ASTNodeCreation] {msg}", level=lvl)
except TypeError: effective_logger(f"[{lvl}][ASTNodeCreation] {msg}")
if value_to_convert is None: return ast.Constant(value=None)
elif isinstance(value_to_convert, (str, int, float, bool, bytes)): return ast.Constant(value=value_to_convert)
elif isinstance(value_to_convert, list): return ast.List(elts=[SpecTransformer._create_ast_node_static(v, effective_logger) for v in value_to_convert], ctx=ast.Load())
elif isinstance(value_to_convert, tuple): return ast.Tuple(elts=[SpecTransformer._create_ast_node_static(v, effective_logger) for v in value_to_convert], ctx=ast.Load())
elif isinstance(value_to_convert, (ast.Name, ast.Attribute, ast.expr)): return value_to_convert
_log_node(f"Cannot convert Python value '{value_to_convert}' (type: {type(value_to_convert)}) to AST node. Raising TypeError.", "WARNING")
raise TypeError(f"Unsupported type for AST conversion: {type(value_to_convert)}")
def _create_ast_node(self, value: Any) -> ast.expr:
return self._create_ast_node_static(value, self.logger)
def visit_Call(self, node: ast.Call) -> ast.Call:
self.generic_visit(node)
if not isinstance(node.func, ast.Name): return node
call_name = node.func.id
if call_name not in self.managed_pyinstaller_args: return node
self._log(f"Visiting Call to: {call_name}", "DEBUG")
existing_keywords = {kw.arg: kw for kw in node.keywords if kw.arg}
gui_keywords_to_set = self.managed_pyinstaller_args.get(call_name, set())
# Decide se un valore GUI deve sovrascrivere il valore esistente nel spec.
def _gui_wants_to_set(value) -> bool:
# Booleans are explicit (True/False) and should be applied.
if isinstance(value, bool):
return True
# None means 'no-op' (do not overwrite)
if value is None:
return False
# Empty sequences/strings are considered not-provided (avoid wiping existing spec values)
if isinstance(value, (list, tuple, dict, str)) and len(value) == 0:
return False
return True
# For Analysis, only clear positional args if the GUI actually provides replacement values
cleared_analysis_args = False
if call_name == 'Analysis':
will_override_scripts = _gui_wants_to_set(self.gui_options.get('analysis_scripts'))
will_override_pathex = _gui_wants_to_set(self.gui_options.get('analysis_pathex'))
# If GUI intends to override either scripts or pathex, we remove positional args to
# avoid ambiguity. Otherwise leave positional args untouched so existing spec semantics
# are preserved.
if will_override_scripts or will_override_pathex:
node.args = []
cleared_analysis_args = True
self._log(" Analysis: Cleared positional args because GUI provides replacements.", "DEBUG")
for key_name in gui_keywords_to_set:
gui_value: Any = None
has_gui_value = False
if call_name == 'Analysis':
if key_name == 'scripts':
gui_value = self.gui_options.get('analysis_scripts')
has_gui_value = _gui_wants_to_set(gui_value)
elif key_name == 'pathex':
gui_value = self.gui_options.get('analysis_pathex')
has_gui_value = _gui_wants_to_set(gui_value)
elif key_name == 'datas':
gui_value = self.gui_options.get('formatted_datas')
has_gui_value = _gui_wants_to_set(gui_value)
elif key_name == 'hiddenimports':
gui_value = self.gui_options.get('formatted_hiddenimports')
has_gui_value = _gui_wants_to_set(gui_value)
elif key_name == 'binaries':
gui_value = self.gui_options.get('formatted_binaries')
has_gui_value = _gui_wants_to_set(gui_value)
elif call_name == 'EXE':
if key_name == 'name':
gui_value = self.gui_options.get('app_name')
has_gui_value = _gui_wants_to_set(gui_value)
elif key_name == 'icon':
gui_value = self.gui_options.get('icon_rel_path')
has_gui_value = _gui_wants_to_set(gui_value)
elif key_name == 'console':
# console is opposite of is_windowed; a False value is valid and should be applied
gui_value = not self.gui_options.get('is_windowed', True)
has_gui_value = True
elif key_name == 'upx':
gui_value = self.gui_options.get('use_upx', True)
has_gui_value = True
elif key_name == 'exclude_binaries':
if not self.gui_options.get('is_onefile', False):
gui_value = True
has_gui_value = True
elif call_name == 'COLLECT':
if key_name == 'name':
gui_value = self.gui_options.get('app_name')
has_gui_value = _gui_wants_to_set(gui_value)
elif key_name == 'upx':
gui_value = self.gui_options.get('use_upx', True)
has_gui_value = True
if has_gui_value:
try:
new_value_node = self._create_ast_node(gui_value)
existing_keywords[key_name] = ast.keyword(arg=key_name, value=new_value_node)
self._log(f" {call_name}: Keyword '{key_name}' set/updated from GUI.", "DEBUG")
except (TypeError, Exception) as e:
self._log(f" {call_name}: Error creating node for keyword '{key_name}': {e}. Skipping.", "WARNING")
node.keywords = list(existing_keywords.values())
return node
def visit_Module(self, node: ast.Module) -> ast.Module:
self.generic_visit(node)
gui_cipher_var_name = self.gui_options.get('pyz_cipher_var_name', 'block_cipher')
if self._is_cipher_used_in_calls(node, gui_cipher_var_name):
has_cipher_assignment = any(
isinstance(stmt, ast.Assign) and
any(isinstance(t, ast.Name) and t.id == gui_cipher_var_name for t in stmt.targets)
for stmt in node.body
)
if not has_cipher_assignment:
cipher_assignment_node = ast.Assign(targets=[ast.Name(id=gui_cipher_var_name, ctx=ast.Store())], value=self._create_ast_node(None))
node.body.insert(0, cipher_assignment_node)
if not self.gui_options.get('is_onefile', False):
has_collect_call = any(
isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Call) and isinstance(stmt.value.func, ast.Name) and stmt.value.func.id == 'COLLECT'
for stmt in node.body
)
if not has_collect_call:
self._log("One-dir mode selected, but no COLLECT call found in spec. Adding it.", "INFO")
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())
collect_node = 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=self.exe_var_name, ctx=ast.Load()), name_attr(self.a_var_name, 'binaries'), name_attr(self.a_var_name, 'zipfiles'), name_attr(self.a_var_name, 'datas')],
keywords=[
ast.keyword(arg='strip', value=self._create_ast_node(False)),
ast.keyword(arg='upx', value=self._create_ast_node(self.gui_options.get('use_upx', True))),
ast.keyword(arg='upx_exclude', value=self._create_ast_node([])),
ast.keyword(arg='name', value=self._create_ast_node(self.gui_options.get('app_name')))
]
)
)
node.body.append(collect_node)
return node
def _is_cipher_used_in_calls(self, module_node: ast.Module, cipher_var_name: str) -> bool:
for node in ast.walk(module_node):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ['Analysis', 'PYZ']:
for kw in node.keywords:
if kw.arg == 'cipher' and isinstance(kw.value, ast.Name) and kw.value.id == cipher_var_name:
return True
return False
def visit_Assign(self, node: ast.Assign) -> ast.Assign:
gui_cipher_var_name = self.gui_options.get('pyz_cipher_var_name')
if gui_cipher_var_name:
for target in node.targets:
if isinstance(target, ast.Name) and target.id == gui_cipher_var_name:
is_already_none = isinstance(node.value, ast.Constant) and node.value.value is None
if not is_already_none:
self._log(f"Forcing assignment to '{target.id}' to None, as it's managed by the GUI.", "INFO")
node.value = self._create_ast_node(None)
break
if isinstance(node.value, ast.AST):
node.value = self.visit(node.value)
return node