# -*- 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", []) # Capture optional hookspath and runtime_hooks if present in Analysis self.parsed_options["hookspath"] = options.get("hookspath", []) self.parsed_options["runtime_hooks"] = options.get("runtime_hooks", []) 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