227 lines
11 KiB
Python
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 |