130 lines
4.4 KiB
Python
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
|