SXXXXXXX_PyInstallerGUIWrapper/spec_parser.py
2025-04-29 11:03:52 +02:00

227 lines
11 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
class _SpecVisitor(ast.NodeVisitor):
"""
An AST Node Visitor to traverse the Python code of a .spec file
and extract relevant PyInstaller configuration details safely.
Reduces logging noise by being more selective about evaluation attempts.
"""
def __init__(self, logger_func=print):
"""
Initialize the visitor.
Args:
logger_func (callable): Function for logging (takes message, level).
"""
self.parsed_options = {}
self.logger = logger_func
self._variable_assignments = {}
def _log(self, message, level="DEBUG"):
""" Helper to call the logger function. """
try:
self.logger(message, level=level)
except TypeError:
self.logger(f"[{level}] {message}")
def _evaluate_node(self, node):
"""
Attempts to evaluate the value of an AST node more quietly.
Handles constants, simple lists/tuples/dicts, and tracked variables.
Returns evaluated Python value, or None if evaluation is complex/unsafe.
Logs warnings ONLY for unresolved variables or errors within structures.
"""
# --- Handle Simple Constants ---
if isinstance(node, ast.Constant): # Python 3.8+
return node.value
elif isinstance(node, (ast.Str, ast.Num, ast.NameConstant)): # Python < 3.8 compat
# Handle deprecated nodes
if isinstance(node, ast.Str): return node.s
if isinstance(node, ast.Num): return node.n
return node.value # For True, False, None
# --- Handle Simple Lists/Tuples ---
elif isinstance(node, (ast.List, ast.Tuple)):
try:
elements = [self._evaluate_node(el) for el in node.elts]
# Check if ANY element failed evaluation (returned None)
if any(el is None for el in elements):
# Log warning here, as we expected to evaluate the elements
self._log(f"Could not evaluate all elements in list/tuple: {ast.dump(node)}", level="WARNING")
return None # Cannot reliably evaluate the whole structure
# If all elements evaluated, return the list/tuple
return elements
except Exception as e:
self._log(f"Error evaluating list/tuple contents: {e} in {ast.dump(node)}", level="WARNING")
return None
# --- Handle Simple Dictionaries ---
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]
# Check if ANY key or value failed evaluation
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
# If all evaluated, return the dict
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
# --- Handle Variable Names ---
elif isinstance(node, ast.Name):
if node.id in self._variable_assignments:
return self._variable_assignments[node.id]
else:
# Log warning here, as we failed to resolve a name we encountered
self._log(f"Cannot resolve variable name during evaluation: {node.id}", level="WARNING")
return None
# --- Handle Other Node Types Silently ---
else:
# For types like ast.Call, ast.Attribute, ast.BinOp, etc.,
# we assume they are too complex to evaluate safely or are not
# simple constants we care about. Return None without logging a warning.
# self._log(f"Skipping evaluation for node type: {type(node)}", level="DEBUG") # Optional debug log
return None
def visit_Assign(self, node):
"""
Visits assignment statements. Tries to track assignments only if the
value appears to be a simple constant or structure. Avoids warnings
for assignments like 'a = Analysis(...)'.
"""
# Check if assignment is to a single variable name
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
var_name = node.targets[0].id
# --- Check if the value is likely evaluatable BEFORE calling _evaluate_node ---
# Only proceed if it looks like a constant, simple structure, or known variable name
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:
# Successfully evaluated, track the assignment
self._variable_assignments[var_name] = value
self._log(f"Tracked assignment: {var_name} = {repr(value)}")
else:
# Evaluation attempted but failed (e.g., unresolved name inside list)
# Log a warning because we expected to evaluate this type
self._log(f"Could not evaluate value for assignment: {var_name} = {ast.dump(node.value)}", level="WARNING")
# else:
# If node.value is e.g. an ast.Call like Analysis(), PYZ(), EXE(),
# do nothing - don't try to evaluate, don't log warning.
# self._log(f"Skipping tracking for complex assignment: {var_name}", level="DEBUG") # Optional debug log
# Continue traversal regardless of whether we tracked the assignment
self.generic_visit(node)
def visit_Call(self, node):
""" Visits function call nodes (e.g., Analysis(...), EXE(...)). """
if isinstance(node.func, ast.Name):
call_name = node.func.id
options = {} # Extracted args for this call
# --- Parse keyword arguments ---
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:
# Log evaluation failures for keyword args as WARNING,
# as these are more likely intended to be simple values.
self._log(f"Could not evaluate keyword argument '{arg_name}' for call '{call_name}'. Value: {ast.dump(keyword.value)}", level="WARNING")
# --- Parse positional arguments ---
# We evaluate them mainly to potentially populate fields like 'scripts'
# if they aren't provided as keywords. Failure to evaluate these
# (e.g., 'pyz' in EXE(pyz, ...)) is less critical, maybe log as DEBUG.
positional_args = []
for i, arg_node in enumerate(node.args):
arg_value = self._evaluate_node(arg_node)
positional_args.append(arg_value)
# Optional: Log failure for positional args at DEBUG level
# if arg_value is None:
# self._log(f"Could not evaluate positional argument {i} for call '{call_name}'. Value: {ast.dump(arg_node)}", level="DEBUG")
# --- Process based on function name ---
# (Logic for Analysis, EXE, COLLECT remains the same)
if call_name == 'Analysis':
self._log(f"Found Analysis() call.")
# Prioritize keyword args, fallback to positional
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', []) # Default to empty list
if not self.parsed_options['pathex'] and len(positional_args) > 1:
self.parsed_options['pathex'] = positional_args[1] or [] # Use empty list if None
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.")
self.parsed_options['name'] = options.get('name')
self.parsed_options['icon'] = options.get('icon')
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
# If 'console' is None (not present or unevaluatable), 'windowed' remains unset here
# Assume onefile=True initially
self.parsed_options['onefile'] = True
elif call_name == 'COLLECT':
self._log(f"Found COLLECT() call.")
# COLLECT overrides the onefile assumption
self.parsed_options['onefile'] = False
# Continue traversal
self.generic_visit(node)
def get_options(self):
""" Returns the dictionary of parsed options. Normalize paths if needed. """
# Normalize icon path if found
if 'icon' in self.parsed_options and self.parsed_options['icon']:
try:
self.parsed_options['icon'] = os.path.normpath(self.parsed_options['icon'])
except Exception as e:
self._log(f"Could not normalize icon path: {self.parsed_options['icon']} ({e})", level="WARNING")
return self.parsed_options
# --- The `parse_spec_file` function remains the same ---
def parse_spec_file(spec_path, logger_func=print):
""" Safely parses a .spec file using AST. """
# (No changes needed in this function itself)
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) # Use updated Visitor
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