# -*- 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 '' except Exception: s = '' 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)