242 lines
10 KiB
Python
242 lines
10 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
|
|
|
|
# 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
|