SXXXXXXX_PyInstallerGUIWrapper/pyinstallerguiwrapper/spec_parser.py
2025-05-05 12:35:58 +02:00

169 lines
8.7 KiB
Python

# -*- coding: utf-8 -*-
"""
Handles parsing of PyInstaller .spec files using Abstract Syntax Trees (AST).
Provides a safe way to extract configuration without executing the spec file.
"""
import ast
import os
# NO CHANGES NEEDED in this file for the restructuring
class _SpecVisitor(ast.NodeVisitor):
"""
An AST Node Visitor to traverse the Python code of a .spec file
and extract relevant PyInstaller configuration details safely.
"""
def __init__(self, logger_func=print):
self.parsed_options = {}
self.logger = logger_func
self._variable_assignments = {}
def _log(self, message, level="DEBUG"):
try:
self.logger(message, level=level)
except TypeError:
self.logger(f"[{level}] {message}")
def _evaluate_node(self, node):
# ... (Function body remains the same as previous version) ...
if isinstance(node, ast.Constant): return node.value
elif isinstance(node, (ast.Str, ast.Num, ast.NameConstant)):
if isinstance(node, ast.Str): return node.s
if isinstance(node, ast.Num): return node.n
return node.value
elif isinstance(node, (ast.List, ast.Tuple)):
try:
elements = [self._evaluate_node(el) for el in node.elts]
if any(el is None for el in elements):
self._log(f"Could not evaluate all elements in list/tuple: {ast.dump(node)}", level="WARNING")
return None
return elements
except Exception as e:
self._log(f"Error evaluating list/tuple contents: {e} in {ast.dump(node)}", level="WARNING")
return None
elif isinstance(node, ast.Dict):
try:
keys = [self._evaluate_node(k) for k in node.keys]
values = [self._evaluate_node(v) for v in node.values]
if any(k is None for k in keys) or any(v is None for v in values):
self._log(f"Could not evaluate all keys/values in dict: {ast.dump(node)}", level="WARNING")
return None
return dict(zip(keys, values))
except Exception as e:
self._log(f"Error evaluating dict contents: {e} in {ast.dump(node)}", level="WARNING")
return None
elif isinstance(node, ast.Name):
if node.id in self._variable_assignments:
return self._variable_assignments[node.id]
else:
self._log(f"Cannot resolve variable name during evaluation: {node.id}", level="WARNING")
return None
else:
return None # Skip complex nodes quietly
def visit_Assign(self, node):
# ... (Function body remains the same as previous version) ...
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
var_name = node.targets[0].id
if isinstance(node.value, (ast.Constant, ast.Str, ast.Num, ast.NameConstant,
ast.List, ast.Tuple, ast.Dict, ast.Name)):
value = self._evaluate_node(node.value)
if value is not None:
self._variable_assignments[var_name] = value
self._log(f"Tracked assignment: {var_name} = {repr(value)}")
else:
self._log(f"Could not evaluate value for assignment: {var_name} = {ast.dump(node.value)}", level="WARNING")
self.generic_visit(node)
def visit_Call(self, node):
# ... (Function body remains the same as previous version) ...
if isinstance(node.func, ast.Name):
call_name = node.func.id
options = {}
for keyword in node.keywords:
arg_name = keyword.arg
value = self._evaluate_node(keyword.value)
if value is not None: options[arg_name] = value
else: self._log(f"Could not evaluate keyword argument '{arg_name}' for call '{call_name}'. Value: {ast.dump(keyword.value)}", level="WARNING")
positional_args = []
for i, arg_node in enumerate(node.args):
arg_value = self._evaluate_node(arg_node)
positional_args.append(arg_value)
if call_name == 'Analysis':
self._log(f"Found Analysis() call.")
self.parsed_options['scripts'] = options.get('scripts')
if self.parsed_options['scripts'] is None and len(positional_args) > 0:
self.parsed_options['scripts'] = positional_args[0]
self.parsed_options['pathex'] = options.get('pathex', [])
if not self.parsed_options['pathex'] and len(positional_args) > 1:
self.parsed_options['pathex'] = positional_args[1] or []
self.parsed_options['datas'] = options.get('datas', [])
self.parsed_options['hiddenimports'] = options.get('hiddenimports', [])
self.parsed_options['binaries'] = options.get('binaries', [])
elif call_name == 'EXE':
self._log(f"Found EXE() call.")
# Use .get() with default None for safety
self.parsed_options['name'] = options.get('name')
# Handle icon path carefully (might be None or empty string)
icon_val = options.get('icon')
self.parsed_options['icon'] = icon_val if icon_val else None # Store None if empty or not present
console_val = options.get('console')
if console_val is True: self.parsed_options['windowed'] = False
elif console_val is False: self.parsed_options['windowed'] = True
# Assume onefile=True unless COLLECT is found
if 'onefile' not in self.parsed_options: # Avoid overwriting if set by COLLECT already
self.parsed_options['onefile'] = True # Default assumption for EXE only
elif call_name == 'COLLECT':
self._log(f"Found COLLECT() call.")
self.parsed_options['onefile'] = False # COLLECT means it's not onefile
self.generic_visit(node)
def get_options(self):
# ... (Function body remains the same as previous version) ...
if 'icon' in self.parsed_options and self.parsed_options['icon']:
try:
# Ensure it's a string before normpath
if isinstance(self.parsed_options['icon'], str):
self.parsed_options['icon'] = os.path.normpath(self.parsed_options['icon'])
else: # If icon is somehow not a string, log and remove
self._log(f"Invalid type for icon path: {type(self.parsed_options['icon'])}. Removing.", level="WARNING")
self.parsed_options['icon'] = None
except Exception as e:
self._log(f"Could not normalize icon path: {self.parsed_options['icon']} ({e})", level="WARNING")
self.parsed_options['icon'] = None # Set to None on error
# Ensure 'datas' is always a list, even if missing or None from spec
self.parsed_options['datas'] = self.parsed_options.get('datas', [])
if not isinstance(self.parsed_options['datas'], list):
self._log(f"Invalid type for 'datas' in spec ({type(self.parsed_options['datas'])}), resetting to empty list.", level="WARNING")
self.parsed_options['datas'] = []
return self.parsed_options
def parse_spec_file(spec_path, logger_func=print):
""" Safely parses a .spec file using AST. """
# ... (Function body remains the same as previous version) ...
logger_func(f"Attempting to parse spec file using AST: {spec_path}", level="INFO")
try:
with open(spec_path, 'r', encoding='utf-8') as f: content = f.read()
tree = ast.parse(content, filename=spec_path)
visitor = _SpecVisitor(logger_func=logger_func)
visitor.visit(tree)
parsed_options = visitor.get_options()
if not parsed_options:
logger_func(f"AST parsing finished, but no recognized PyInstaller options found in {spec_path}", level="WARNING")
else:
logger_func("Successfully parsed basic options from spec file.", level="INFO")
return parsed_options
except FileNotFoundError:
logger_func(f"Spec file not found: {spec_path}", level="ERROR"); return None
except SyntaxError as e:
logger_func(f"Syntax error in spec file '{spec_path}': {e}", level="ERROR"); return None
except Exception as e:
logger_func(f"Unexpected error during AST parsing of '{spec_path}': {e}", level="ERROR")
import traceback; logger_func(traceback.format_exc(), level="DEBUG"); return None