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