SXXXXXXX_PyInstallerGUIWrapper/pyinstallerguiwrapper/gui/spec_editor_panel.py

158 lines
7.0 KiB
Python

# -*- coding: utf-8 -*-
"""
Spec Editor Panel
Provides a structured UI to inspect and edit Analysis/EXE/COLLECT portions
of a PyInstaller .spec file using the `pyinstallerguiwrapper.spec_editor` API.
This is intentionally conservative: users edit Python literal values for
keywords (lists, strings, booleans, dicts) which are converted back to AST
nodes and written to the spec.
"""
import tkinter as tk
from tkinter import ttk, messagebox
import pathlib
import ast
from typing import Callable, Optional
from pyinstallerguiwrapper import spec_editor
class SpecEditorPanel(ttk.Frame):
def __init__(self, parent, get_spec_file_path_func: Callable[[], Optional[str]], logger_func: Callable[[str, str], None]):
super().__init__(parent)
self.get_spec_file_path = get_spec_file_path_func
self.logger = logger_func
# UI
self.call_type_var = tk.StringVar(value='Analysis')
self.call_select = ttk.Combobox(self, textvariable=self.call_type_var, values=['Analysis', 'EXE', 'COLLECT', 'PYZ'], state='readonly')
self.refresh_calls_btn = ttk.Button(self, text='Refresh', command=self.refresh_calls)
self.keywords_listbox = tk.Listbox(self, height=8)
self.keywords_scroll = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.keywords_listbox.yview)
self.keywords_listbox.config(yscrollcommand=self.keywords_scroll.set)
self.value_text = tk.Text(self, height=10, width=80)
self.load_keyword_btn = ttk.Button(self, text='Load Keyword', command=self.load_selected_keyword)
self.apply_keyword_btn = ttk.Button(self, text='Apply Keyword', command=self.apply_keyword_change)
self.save_spec_btn = ttk.Button(self, text='Save Spec', command=self.save_spec_file)
# Layout
self.call_select.grid(row=0, column=0, sticky='w', padx=5, pady=5)
self.refresh_calls_btn.grid(row=0, column=1, sticky='w', padx=5, pady=5)
self.keywords_listbox.grid(row=1, column=0, rowspan=3, sticky='nsew', padx=(5,0), pady=5)
self.keywords_scroll.grid(row=1, column=1, rowspan=3, sticky='ns', padx=(0,5), pady=5)
self.value_text.grid(row=1, column=2, columnspan=2, sticky='nsew', padx=5, pady=5)
self.load_keyword_btn.grid(row=2, column=2, sticky='w', padx=5, pady=5)
self.apply_keyword_btn.grid(row=2, column=3, sticky='w', padx=5, pady=5)
self.save_spec_btn.grid(row=3, column=3, sticky='e', padx=5, pady=5)
self.columnconfigure(2, weight=1)
self.rowconfigure(1, weight=1)
# Internal
self._ast_tree = None
self._source_text = None
# Do an initial silent refresh (do not show messageboxes when no spec)
self.refresh_calls(silent=True)
def _log(self, msg: str, level: str = 'INFO'):
try:
self.logger(msg, level)
except Exception:
print(f"[{level}] {msg}")
def refresh_calls(self, silent: bool = False):
spec_path = self.get_spec_file_path()
if not spec_path or not pathlib.Path(spec_path).is_file():
# Do not interrupt the startup flow with modal dialogs if called silently.
msg = 'Spec file not found for current project. Load a project with a spec first.'
if silent:
self._log(msg, 'WARNING')
return
else:
messagebox.showwarning('Spec Not Found', msg, parent=self)
return
try:
tree, src = spec_editor.parse_spec_to_ast(spec_path)
self._ast_tree = tree
self._source_text = src
call_name = self.call_type_var.get()
calls = spec_editor.find_calls(tree, call_name)
self.keywords_listbox.delete(0, tk.END)
if calls:
first = calls[0]
for kw in first.keywords:
if kw.arg:
self.keywords_listbox.insert(tk.END, kw.arg)
else:
self._log(f'No {call_name} call found in spec.', 'WARNING')
self._log(f'Refreshed calls for {spec_path}', 'DEBUG')
except Exception as e:
self._log(f'Failed to parse/refresh spec: {e}', 'ERROR')
messagebox.showerror('Parse Error', f'Error parsing spec: {e}', parent=self)
def load_selected_keyword(self):
sel = self.keywords_listbox.curselection()
if not sel:
return
kw = self.keywords_listbox.get(sel[0])
call_name = self.call_type_var.get()
calls = spec_editor.find_calls(self._ast_tree, call_name)
if not calls:
messagebox.showwarning('Not Found', f'No {call_name} call found.', parent=self)
return
node = spec_editor.get_keyword_value(calls[0], kw)
if node is None:
self.value_text.delete('1.0', tk.END)
self.value_text.insert(tk.END, '')
return
try:
s = ast.unparse(node) if hasattr(ast, 'unparse') else '<unparse unavailable>'
except Exception:
s = '<unparse unavailable>'
self.value_text.delete('1.0', tk.END)
self.value_text.insert(tk.END, s)
def apply_keyword_change(self):
sel = self.keywords_listbox.curselection()
if not sel:
messagebox.showinfo('No Selection', 'Select a keyword to apply change to.', parent=self)
return
kw = self.keywords_listbox.get(sel[0])
raw = self.value_text.get('1.0', tk.END).strip()
# Try to parse the raw text into a Python literal safely
try:
py_val = ast.literal_eval(raw)
except Exception:
# As a fallback, treat it as a string
py_val = raw
modified = spec_editor.set_call_keyword(self._ast_tree, self.call_type_var.get(), kw, py_val)
if modified:
messagebox.showinfo('Applied', f'Keyword "{kw}" updated in memory. Click Save Spec to write file.', parent=self)
self._log(f'Keyword {kw} updated in AST (in-memory).', 'INFO')
else:
messagebox.showerror('Failed', 'Failed to apply the change. See log.', parent=self)
def save_spec_file(self):
if self._ast_tree is None or self._source_text is None:
messagebox.showwarning('Nothing to Save', 'Load a spec file first.', parent=self)
return
try:
new_src = spec_editor.ast_to_source(self._ast_tree)
spec_path = self.get_spec_file_path()
if not spec_path:
messagebox.showerror('No Spec Path', 'Spec file path not available.', parent=self)
return
spec_editor.write_source_to_file(new_src, spec_path)
messagebox.showinfo('Saved', f'Spec saved to: {spec_path}', parent=self)
self._log(f'Spec file saved: {spec_path}', 'INFO')
# Refresh to reflect saved keywords
self.refresh_calls()
except Exception as e:
self._log(f'Failed to save spec: {e}', 'ERROR')
messagebox.showerror('Save Failed', f'Failed to save spec: {e}', parent=self)