# -*- 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