220 lines
12 KiB
Python
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 |