SXXXXXXX_PyInstallerGUIWrapper/pyinstallerguiwrapper/spec_editor.py

130 lines
4.4 KiB
Python

# -*- coding: utf-8 -*-
"""
Utility helpers to inspect and programmatically edit PyInstaller .spec files
via their AST. Provides a small, conservative API to find calls (Analysis, EXE,
COLLECT, PYZ), read and update keyword arguments, and serialize back to source.
This is intentionally minimal and conservative: it only replaces or inserts
keyword arguments for existing calls and relies on the existing
`SpecTransformer._create_ast_node_static` for creating AST nodes from Python
values so that the produced nodes are compatible with the rest of the tool.
"""
from __future__ import annotations
import ast
import sys
from typing import Any, List, Optional, Tuple
from pyinstallerguiwrapper.gui.ast_transformer import SpecTransformer
def parse_spec_to_ast(spec_path: str) -> Tuple[ast.Module, str]:
"""Read a .spec file and return its AST and original source text."""
with open(spec_path, 'r', encoding='utf-8') as f:
source = f.read()
tree = ast.parse(source, filename=spec_path)
return tree, source
def parse_spec_from_string(source: str) -> ast.Module:
"""Parse a .spec provided as a string and return the AST Module."""
return ast.parse(source)
def ast_node_to_python(node: ast.AST) -> Any:
"""Try to convert a simple AST literal node to its Python value.
Uses ast.literal_eval when possible. Returns None if conversion is not
supported (e.g., complex expressions, attributes, names).
"""
try:
return ast.literal_eval(node)
except Exception:
return None
def find_calls(tree: ast.Module, call_name: str) -> List[ast.Call]:
"""Return a list of ast.Call nodes whose function is a Name equal to
`call_name` (e.g., 'Analysis', 'EXE', 'COLLECT', 'PYZ')."""
results: List[ast.Call] = []
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == call_name:
results.append(node)
return results
def get_keyword_value(call_node: ast.Call, keyword: str) -> Optional[ast.expr]:
"""Return the AST node for the specified keyword in the call, or None."""
for kw in call_node.keywords:
if kw.arg == keyword:
return kw.value
return None
def set_call_keyword(tree: ast.Module, call_name: str, keyword: str, value: Any) -> bool:
"""Set (or add) the keyword `keyword` to `value` on the first `call_name`
call found in `tree`. Returns True if a modification was made.
The `value` will be converted to an AST node using
`SpecTransformer._create_ast_node_static` for compatibility.
"""
calls = find_calls(tree, call_name)
if not calls:
return False
call = calls[0]
# Create AST node from Python value using the transformer helper.
try:
node_value = SpecTransformer._create_ast_node_static(value)
except Exception:
# If conversion fails, do not modify.
return False
# Replace existing keyword if present
replaced = False
for i, kw in enumerate(call.keywords):
if kw.arg == keyword:
call.keywords[i] = ast.keyword(arg=keyword, value=node_value)
replaced = True
break
if not replaced:
call.keywords.append(ast.keyword(arg=keyword, value=node_value))
return True
def ast_to_source(tree: ast.Module) -> str:
"""Serialize AST back to source. Uses `ast.unparse` for Python >= 3.9 or
falls back to `astor` when available.
"""
ast.fix_missing_locations(tree)
if sys.version_info >= (3, 9):
return ast.unparse(tree)
else:
try:
import astor # type: ignore
return astor.to_source(tree)
except Exception as e:
raise RuntimeError("ast.unparse not available and astor not installed") from e
def write_source_to_file(source: str, path: str) -> None:
with open(path, 'w', encoding='utf-8') as f:
f.write(source)
def inspect_spec_summary(tree: ast.Module) -> dict:
"""Return a small summary of the spec AST: which call names present and
counts of keywords found in the first match for Analysis/EXE/COLLECT.
"""
summary = {}
for name in ('Analysis', 'PYZ', 'EXE', 'COLLECT'):
calls = find_calls(tree, name)
if calls:
first = calls[0]
kw_names = [kw.arg for kw in first.keywords if kw.arg]
summary[name] = {'count': len(calls), 'first_keywords': kw_names}
else:
summary[name] = {'count': 0, 'first_keywords': []}
return summary