refactoring with new name
This commit is contained in:
parent
59109d3a18
commit
c215604251
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": "Python Debugger: Module",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "pyinstallerguiwrapper"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(scripts=['C:\\src\\____GitProjects\\CreateExecFromPy\\CreateExecFromPy.py'], pathex=['C:\\src\\____GitProjects\\CreateExecFromPy'], binaries=[], datas=[], hiddenimports=[], hookspath=[], runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=None, noarchive=False,)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=None)
|
||||
|
||||
exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='CreateExecFromPy',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,)
|
||||
115
builder.py
115
builder.py
@ -1,115 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" builder.py - Handles the execution of the PyInstaller build process in a separate thread. """
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import queue
|
||||
import os
|
||||
import traceback # For logging exceptions
|
||||
|
||||
def run_build_in_thread(command, working_dir, output_queue, logger_func, output_dir_name, environment=None):
|
||||
"""
|
||||
Executes the PyInstaller command using subprocess.Popen in the background.
|
||||
|
||||
Designed to be run in a separate thread. Captures stdout/stderr and sends
|
||||
messages (LOG, BUILD_SUCCESS, BUILD_ERROR, BUILD_FINISHED) back to the
|
||||
main GUI thread via the provided queue.
|
||||
|
||||
Args:
|
||||
command (list): The command list to execute (e.g., ['pyinstaller', ...]).
|
||||
working_dir (str): The directory where the command should be executed.
|
||||
output_queue (queue.Queue): The queue to send status messages back.
|
||||
logger_func (callable): Function for logging (takes message, level).
|
||||
output_dir_name (str): Expected output directory name (e.g., '_dist').
|
||||
environment (dict, optional): Environment variables for the subprocess.
|
||||
Defaults to None (inherit/use system default).
|
||||
"""
|
||||
build_process = None # Define variable outside try block for broader scope if needed
|
||||
try:
|
||||
logger_func("Build thread starting execution...", level="INFO")
|
||||
|
||||
# Log the environment being used if it's custom
|
||||
env_log_msg = f"Using {'custom' if environment else 'inherited'} environment."
|
||||
if environment:
|
||||
# Optionally log specific variables for debugging (be careful with sensitive data)
|
||||
# env_details = {k: v for k, v in environment.items() if 'TCL' in k or 'TK' in k or 'PATH' in k}
|
||||
# env_log_msg += f" Details (partial): {env_details}"
|
||||
pass # Avoid logging full env by default
|
||||
logger_func(f"PyInstaller process starting. {env_log_msg}", level="INFO")
|
||||
|
||||
|
||||
# Start the PyInstaller process
|
||||
build_process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, # Redirect stderr to stdout
|
||||
cwd=working_dir, # Set working directory
|
||||
text=True, # Decode output as text
|
||||
encoding='utf-8', # Specify encoding
|
||||
errors='replace', # Handle decoding errors gracefully
|
||||
bufsize=1, # Line-buffered output
|
||||
env=environment # Pass the specific environment
|
||||
# Note: creationflags might be needed on Windows to hide console,
|
||||
# e.g., creationflags=subprocess.CREATE_NO_WINDOW
|
||||
)
|
||||
logger_func(f"PyInstaller process launched (PID: {build_process.pid}).", level="INFO")
|
||||
|
||||
# --- Read output line by line in real-time ---
|
||||
while True:
|
||||
try:
|
||||
line = build_process.stdout.readline()
|
||||
if not line:
|
||||
# Check if process ended before breaking the loop
|
||||
if build_process.poll() is not None:
|
||||
logger_func("End of PyInstaller output stream.", level="DEBUG")
|
||||
break
|
||||
else:
|
||||
# Process might still be running, briefly wait or continue loop
|
||||
pass # readline should block, so usually no need to sleep
|
||||
else:
|
||||
# Send the captured line to the GUI log via the queue
|
||||
output_queue.put(("LOG", line))
|
||||
except Exception as read_err:
|
||||
# Log errors during stream reading
|
||||
logger_func(f"Error reading PyInstaller output stream: {read_err}\n{traceback.format_exc()}", level="ERROR")
|
||||
# Decide whether to break or continue based on the error
|
||||
break # Safer to break if reading fails
|
||||
|
||||
# --- Wait for process completion and get exit code ---
|
||||
return_code = build_process.wait()
|
||||
logger_func(f"PyInstaller process finished with exit code: {return_code}", level="INFO")
|
||||
|
||||
# --- Send final status message to the queue ---
|
||||
if return_code == 0:
|
||||
dist_path_abs = os.path.join(working_dir, output_dir_name)
|
||||
if os.path.exists(dist_path_abs):
|
||||
success_msg = f"Build completato con successo!\nDirectory Output: {dist_path_abs}"
|
||||
output_queue.put(("BUILD_SUCCESS", success_msg))
|
||||
else:
|
||||
# Build reported success, but output dir is missing!
|
||||
warn_msg = (f"Build terminato con codice 0, ma directory output non trovata:\n"
|
||||
f"{dist_path_abs}\nControlla il log completo per eventuali problemi.")
|
||||
logger_func(warn_msg, level="WARNING")
|
||||
output_queue.put(("BUILD_ERROR", warn_msg)) # Report as error to user
|
||||
else:
|
||||
# Build failed
|
||||
error_msg = f"Build fallito! (Codice Uscita: {return_code})\nControlla il log per dettagli."
|
||||
output_queue.put(("BUILD_ERROR", error_msg))
|
||||
|
||||
except FileNotFoundError:
|
||||
# Handle case where the pyinstaller command itself isn't found
|
||||
error_msg = ("Errore: Comando 'pyinstaller' non trovato.\n"
|
||||
"Assicurati che PyInstaller sia installato correttamente e nel PATH.")
|
||||
output_queue.put(("BUILD_ERROR", error_msg))
|
||||
logger_func(error_msg, level="CRITICAL")
|
||||
except Exception as e:
|
||||
# Handle any other unexpected exceptions during subprocess management
|
||||
error_msg = f"Errore inatteso durante l'esecuzione del processo di build: {e}"
|
||||
output_queue.put(("BUILD_ERROR", error_msg))
|
||||
logger_func(error_msg, level="CRITICAL")
|
||||
logger_func(traceback.format_exc(), level="DEBUG") # Log full traceback for debugging
|
||||
finally:
|
||||
# --- Crucial: Always signal that the build finished ---
|
||||
# This allows the main thread to re-enable the build button etc.
|
||||
output_queue.put(("BUILD_FINISHED", None)) # Send finish signal
|
||||
logger_func("Build thread finished.", level="INFO")
|
||||
55
config.py
55
config.py
@ -1,55 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Configuration constants for the PyInstaller GUI Wrapper application.
|
||||
"""
|
||||
|
||||
# Default values for PyInstaller options managed by the GUI
|
||||
# These are used initially and when resetting options.
|
||||
DEFAULT_SPEC_OPTIONS = {
|
||||
'onefile': False, # Corresponds to --onefile/--onedir
|
||||
'windowed': True, # Corresponds to --windowed/--console (--noconsole)
|
||||
'name': '', # Application name, derived from script if empty
|
||||
'icon': '', # Path to the application icon (.ico on Win, .icns on Mac)
|
||||
'add_data': [], # List of tuples (source_path, destination_in_bundle)
|
||||
'hidden_imports': [], # List of modules PyInstaller might miss
|
||||
'log_level': 'INFO', # PyInstaller log verbosity
|
||||
'clean_build': True, # Corresponds to --clean (remove cache before build)
|
||||
'confirm_overwrite': False,# Corresponds to --noconfirm (True means --noconfirm is active)
|
||||
'output_dir_name': '_dist', # Custom name for the output directory (default is 'dist')
|
||||
'work_dir_name': '_build', # Custom name for the temporary build directory (default is 'build')
|
||||
'use_upx': True, # Whether to attempt using UPX if available
|
||||
# Internal tracking, potentially populated by parser, used by generator
|
||||
'scripts': [], # Track main script(s) from Analysis
|
||||
'pathex': [], # Track pathex from Analysis
|
||||
'binaries': [], # Track binaries from Analysis
|
||||
}
|
||||
|
||||
# Log levels available in PyInstaller (for potential dropdown/validation)
|
||||
PYINSTALLER_LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"]
|
||||
|
||||
# Add other constants here as needed, e.g., file type filters
|
||||
SCRIPT_FILE_TYPES = [
|
||||
("Python files", "*.py"),
|
||||
("Pythonw files", "*.pyw"),
|
||||
("All files", "*.*")
|
||||
]
|
||||
|
||||
ICON_FILE_TYPES_WINDOWS = [
|
||||
("Icon files", "*.ico"),
|
||||
("All files", "*.*")
|
||||
]
|
||||
ICON_DEFAULT_EXT_WINDOWS = ".ico"
|
||||
|
||||
ICON_FILE_TYPES_MACOS = [
|
||||
("Apple Icon files", "*.icns"),
|
||||
("PNG images", "*.png"),
|
||||
("All files", "*.*")
|
||||
]
|
||||
ICON_DEFAULT_EXT_MACOS = ".icns"
|
||||
|
||||
ICON_FILE_TYPES_LINUX = [
|
||||
("Image files", "*.png"),
|
||||
("Icon files", "*.ico"),
|
||||
("All files", "*.*")
|
||||
]
|
||||
ICON_DEFAULT_EXT_LINUX = ".png"
|
||||
460
gui.py
460
gui.py
@ -1,460 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" gui.py - Main GUI module for the PyInstaller Wrapper. """
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from tkinter import filedialog
|
||||
from tkinter import messagebox
|
||||
from tkinter import scrolledtext
|
||||
import tkinter.simpledialog as simpledialog # Needed for Add Data destination prompt
|
||||
import os # Needed for path manipulation and environ
|
||||
import sys # Needed for sys.platform
|
||||
import threading
|
||||
import queue
|
||||
import traceback # For detailed error logging
|
||||
import shutil # Needed for shutil.which()
|
||||
|
||||
# Import other modules from the package/directory
|
||||
import config
|
||||
import spec_parser
|
||||
import builder
|
||||
|
||||
class PyInstallerGUI(tk.Tk):
|
||||
""" Main application window for the PyInstaller GUI tool. """
|
||||
def __init__(self):
|
||||
""" Initialize the main application window, variables, and widgets. """
|
||||
super().__init__()
|
||||
self.title("PyInstaller GUI Wrapper")
|
||||
# Consider setting a minimum size, especially with the added tab
|
||||
# self.minsize(650, 550)
|
||||
|
||||
# --- Tkinter Variables (Basic Options) ---
|
||||
self.python_script_path = tk.StringVar()
|
||||
self.spec_file_path = tk.StringVar()
|
||||
self.icon_path = tk.StringVar()
|
||||
self.app_name = tk.StringVar()
|
||||
self.is_onefile = tk.BooleanVar(value=config.DEFAULT_SPEC_OPTIONS['onefile'])
|
||||
self.is_windowed = tk.BooleanVar(value=config.DEFAULT_SPEC_OPTIONS['windowed'])
|
||||
self.log_level = tk.StringVar(value=config.DEFAULT_SPEC_OPTIONS['log_level'])
|
||||
|
||||
# --- Data Structures for Complex Options ---
|
||||
# List to hold tuples: (absolute_source_path, destination_in_bundle)
|
||||
self.added_data_list = []
|
||||
|
||||
# --- Build Process State ---
|
||||
self.build_thread = None
|
||||
self.build_queue = queue.Queue()
|
||||
|
||||
# --- Initialize GUI ---
|
||||
self.main_frame = ttk.Frame(self, padding="10")
|
||||
self.main_frame.grid(row=0, column=0, sticky="nsew")
|
||||
self.rowconfigure(0, weight=1)
|
||||
self.columnconfigure(0, weight=1)
|
||||
|
||||
self._create_widgets(self.main_frame)
|
||||
self._layout_widgets()
|
||||
|
||||
# Start monitoring the build queue
|
||||
self.after(100, self._check_build_queue)
|
||||
self._log_to_gui("Applicazione inizializzata.", level="INFO")
|
||||
|
||||
# --- Widget Creation ---
|
||||
def _create_widgets(self, parent_frame):
|
||||
""" Create all the necessary GUI widgets. """
|
||||
# --- Section 1: Input Script ---
|
||||
self.file_frame = ttk.LabelFrame(parent_frame, text="Input Script")
|
||||
self.script_label = ttk.Label(self.file_frame, text="Python Script:")
|
||||
self.script_entry = ttk.Entry(self.file_frame, textvariable=self.python_script_path, state="readonly", width=70)
|
||||
self.script_button_browse = ttk.Button(self.file_frame, text="Sfoglia...", command=self._select_script)
|
||||
|
||||
# --- Section 2: Core Options (using Notebook for Tabs) ---
|
||||
self.options_notebook = ttk.Notebook(parent_frame)
|
||||
|
||||
# --- Tab 1: Basic Options ---
|
||||
self.basic_options_frame = ttk.Frame(self.options_notebook, padding="10")
|
||||
self.options_notebook.add(self.basic_options_frame, text="Opzioni Base")
|
||||
|
||||
self.name_label = ttk.Label(self.basic_options_frame, text="Nome App:")
|
||||
self.name_entry = ttk.Entry(self.basic_options_frame, textvariable=self.app_name)
|
||||
self.icon_label = ttk.Label(self.basic_options_frame, text="File Icona:")
|
||||
self.icon_entry = ttk.Entry(self.basic_options_frame, textvariable=self.icon_path, state="readonly", width=60)
|
||||
self.icon_button_browse = ttk.Button(self.basic_options_frame, text="Sfoglia...", command=self._select_icon)
|
||||
self.icon_button_clear = ttk.Button(self.basic_options_frame, text="Pulisci", command=self._clear_icon)
|
||||
self.onefile_check = ttk.Checkbutton(self.basic_options_frame, text="File Singolo (Unico eseguibile)", variable=self.is_onefile)
|
||||
self.windowed_check = ttk.Checkbutton(self.basic_options_frame, text="Finestra (Nessuna console)", variable=self.is_windowed)
|
||||
|
||||
# --- Tab 2: File Aggiuntivi ---
|
||||
self.data_files_frame = ttk.Frame(self.options_notebook, padding="10")
|
||||
self.options_notebook.add(self.data_files_frame, text="File Aggiuntivi")
|
||||
|
||||
self.data_list_label = ttk.Label(self.data_files_frame, text="File/Cartelle da includere (Sorgente -> Destinazione nel bundle):")
|
||||
self.data_list_scrollbar_y = ttk.Scrollbar(self.data_files_frame, orient=tk.VERTICAL)
|
||||
self.data_list_scrollbar_x = ttk.Scrollbar(self.data_files_frame, orient=tk.HORIZONTAL)
|
||||
self.data_listbox = tk.Listbox(
|
||||
self.data_files_frame,
|
||||
selectmode=tk.SINGLE, # Allow only one selection for removal
|
||||
yscrollcommand=self.data_list_scrollbar_y.set,
|
||||
xscrollcommand=self.data_list_scrollbar_x.set,
|
||||
height=6, width=70
|
||||
)
|
||||
self.data_list_scrollbar_y.config(command=self.data_listbox.yview)
|
||||
self.data_list_scrollbar_x.config(command=self.data_listbox.xview)
|
||||
|
||||
self.data_buttons_frame = ttk.Frame(self.data_files_frame)
|
||||
self.data_button_add_file = ttk.Button(self.data_buttons_frame, text="Aggiungi File...", command=self._add_data_file)
|
||||
self.data_button_add_dir = ttk.Button(self.data_buttons_frame, text="Aggiungi Cartella...", command=self._add_data_directory)
|
||||
self.data_button_remove = ttk.Button(self.data_buttons_frame, text="Rimuovi Selezionato", command=self._remove_selected_data)
|
||||
|
||||
|
||||
# --- Section 3: Build Output Log ---
|
||||
self.output_frame = ttk.LabelFrame(parent_frame, text="Log di Build")
|
||||
self.output_text = scrolledtext.ScrolledText(self.output_frame, wrap=tk.WORD, height=15, state="disabled")
|
||||
|
||||
# --- Section 4: Action Buttons ---
|
||||
self.action_frame = ttk.Frame(parent_frame)
|
||||
self.build_button = ttk.Button(self.action_frame, text="Crea Eseguibile", command=self._start_build, state="disabled")
|
||||
|
||||
|
||||
# --- Layout Widgets ---
|
||||
def _layout_widgets(self):
|
||||
""" Arrange widgets using grid layout. """
|
||||
self.main_frame.columnconfigure(0, weight=1)
|
||||
self.main_frame.rowconfigure(1, weight=0) # Notebook row
|
||||
self.main_frame.rowconfigure(2, weight=1) # Output log expands
|
||||
|
||||
# Layout Script Selection
|
||||
self.file_frame.grid(row=0, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||||
self.file_frame.columnconfigure(1, weight=1)
|
||||
self.script_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
||||
self.script_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew")
|
||||
self.script_button_browse.grid(row=0, column=2, padx=5, pady=5)
|
||||
|
||||
# Layout Options Notebook
|
||||
self.options_notebook.grid(row=1, column=0, padx=5, pady=5, sticky="ew")
|
||||
|
||||
# --- Layout Basic Options Tab ---
|
||||
self.basic_options_frame.columnconfigure(1, weight=1)
|
||||
self.name_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
|
||||
self.name_entry.grid(row=0, column=1, columnspan=3, padx=5, pady=5, sticky="ew")
|
||||
self.icon_label.grid(row=1, column=0, padx=5, pady=5, sticky="w")
|
||||
self.icon_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew")
|
||||
self.icon_button_browse.grid(row=1, column=2, padx=(0, 2), pady=5)
|
||||
self.icon_button_clear.grid(row=1, column=3, padx=(2, 5), pady=5)
|
||||
self.onefile_check.grid(row=2, column=0, columnspan=4, padx=5, pady=5, sticky="w")
|
||||
self.windowed_check.grid(row=3, column=0, columnspan=4, padx=5, pady=5, sticky="w")
|
||||
|
||||
# --- Layout Data Files Tab ---
|
||||
self.data_files_frame.columnconfigure(0, weight=1) # Listbox expands horizontally
|
||||
self.data_files_frame.rowconfigure(1, weight=1) # Listbox expands vertically
|
||||
self.data_list_label.grid(row=0, column=0, columnspan=2, padx=5, pady=(0, 5), sticky="w")
|
||||
self.data_listbox.grid(row=1, column=0, padx=(5, 0), pady=5, sticky="nsew")
|
||||
self.data_list_scrollbar_y.grid(row=1, column=1, padx=(0, 5), pady=5, sticky="ns")
|
||||
self.data_list_scrollbar_x.grid(row=2, column=0, padx=5, pady=(0, 5), sticky="ew")
|
||||
self.data_buttons_frame.grid(row=1, column=2, rowspan=2, padx=5, pady=5, sticky="ns")
|
||||
self.data_button_add_file.pack(pady=2, fill=tk.X)
|
||||
self.data_button_add_dir.pack(pady=2, fill=tk.X)
|
||||
self.data_button_remove.pack(pady=2, fill=tk.X)
|
||||
|
||||
# Layout Build Output Log
|
||||
self.output_frame.grid(row=2, column=0, padx=5, pady=5, sticky="nsew")
|
||||
self.output_frame.rowconfigure(0, weight=1); self.output_frame.columnconfigure(0, weight=1)
|
||||
self.output_text.grid(row=0, column=0, padx=5, pady=5, sticky="nsew")
|
||||
|
||||
# Layout Action Frame
|
||||
self.action_frame.grid(row=3, column=0, padx=5, pady=(5, 0), sticky="e")
|
||||
self.build_button.pack(pady=5)
|
||||
|
||||
|
||||
# --- Callback Methods ---
|
||||
|
||||
def _select_script(self):
|
||||
""" Handles script selection, derives defaults, checks spec, updates GUI. """
|
||||
self._log_to_gui("="*20 + " SELEZIONE SCRIPT INIZIATA " + "="*20, level="INFO")
|
||||
file_path = filedialog.askopenfilename(title="Seleziona Script Python", filetypes=config.SCRIPT_FILE_TYPES)
|
||||
if not file_path: self._log_to_gui("Selezione script cancellata.", level="INFO"); return
|
||||
|
||||
self.python_script_path.set(file_path)
|
||||
self._log_to_gui(f"Selezionato script: {file_path}", level="INFO")
|
||||
script_dir = os.path.dirname(file_path)
|
||||
script_filename = os.path.basename(file_path)
|
||||
script_name_without_ext = os.path.splitext(script_filename)[0]
|
||||
potential_spec_path = os.path.join(script_dir, f"{script_name_without_ext}.spec")
|
||||
self.spec_file_path.set(potential_spec_path)
|
||||
|
||||
self._reset_options_to_defaults(derive_name=True) # Reset includes data list
|
||||
self._handle_existing_spec_file(potential_spec_path) # Parse spec if exists
|
||||
self.build_button.config(state="normal")
|
||||
self._log_to_gui("Pronto. Rivedi le opzioni o clicca 'Crea Eseguibile'.", level="INFO")
|
||||
|
||||
def _handle_existing_spec_file(self, spec_path):
|
||||
""" Checks for and parses an existing spec file using spec_parser. """
|
||||
if os.path.exists(spec_path):
|
||||
self._log_to_gui("-" * 15 + " Parsing Spec File Esistente " + "-" * 15, level="INFO")
|
||||
self._log_to_gui(f"Trovato file spec esistente: {spec_path}", level="INFO")
|
||||
parsed_options = spec_parser.parse_spec_file(spec_path, logger_func=self._log_to_gui)
|
||||
|
||||
if parsed_options is not None:
|
||||
if parsed_options:
|
||||
self._update_gui_from_options(parsed_options) # Includes data list
|
||||
messagebox.showinfo("Spec File Caricato", f"Caricate opzioni da:\n{spec_path}\nCampi GUI aggiornati.")
|
||||
else:
|
||||
messagebox.showwarning("Spec File Parsato - Opzioni non Riconosciute", f"Parsato file spec:\n{spec_path}\n\nNessuna opzione comune riconosciuta.\nBuild userà impostazioni GUI e sovrascriverà lo spec.")
|
||||
else:
|
||||
messagebox.showerror("Errore Parsing Spec File", f"Fallito il parsing del file spec:\n{spec_path}\n\nControlla log. Procedere sovrascriverà lo spec.")
|
||||
else:
|
||||
self._log_to_gui(f"Nessun file spec trovato in: {spec_path}. Ne verrà generato uno nuovo.", level="INFO")
|
||||
|
||||
|
||||
def _reset_options_to_defaults(self, derive_name=True):
|
||||
""" Resets GUI option widgets and internal data lists to defaults. """
|
||||
self._log_to_gui("Resetting opzioni ai valori di default.", level="INFO")
|
||||
default_app_name = ""
|
||||
if derive_name:
|
||||
script_file = self.python_script_path.get()
|
||||
if script_file: default_app_name = os.path.splitext(os.path.basename(script_file))[0]
|
||||
|
||||
self.app_name.set(default_app_name or config.DEFAULT_SPEC_OPTIONS['name'])
|
||||
self.icon_path.set(config.DEFAULT_SPEC_OPTIONS['icon'])
|
||||
self.is_onefile.set(config.DEFAULT_SPEC_OPTIONS['onefile'])
|
||||
self.is_windowed.set(config.DEFAULT_SPEC_OPTIONS['windowed'])
|
||||
self.log_level.set(config.DEFAULT_SPEC_OPTIONS['log_level'])
|
||||
|
||||
self.added_data_list.clear()
|
||||
self.data_listbox.delete(0, tk.END)
|
||||
self._log_to_gui("Lista file aggiuntivi resettata.", level="DEBUG")
|
||||
|
||||
|
||||
def _update_gui_from_options(self, options):
|
||||
""" Updates GUI widgets AND data lists based on parsed options. """
|
||||
self._log_to_gui("Aggiornamento GUI dalle opzioni spec caricate/parsate.", level="INFO")
|
||||
# Update basic options
|
||||
if 'name' in options and options['name'] is not None: self.app_name.set(options['name'])
|
||||
if 'icon' in options and options['icon'] is not None: self.icon_path.set(options['icon'])
|
||||
else: self.icon_path.set("")
|
||||
if 'onefile' in options and isinstance(options.get('onefile'), bool): self.is_onefile.set(options['onefile'])
|
||||
if 'windowed' in options and isinstance(options.get('windowed'), bool): self.is_windowed.set(options['windowed'])
|
||||
if 'log_level' in options and options.get('log_level') in config.PYINSTALLER_LOG_LEVELS: self.log_level.set(options['log_level'])
|
||||
|
||||
# Update Added Data list/listbox
|
||||
self.added_data_list.clear()
|
||||
self.data_listbox.delete(0, tk.END)
|
||||
parsed_datas = options.get('datas', [])
|
||||
if parsed_datas:
|
||||
self._log_to_gui(f"Trovati 'datas' nello spec: {len(parsed_datas)} voci.", level="INFO")
|
||||
script_dir = os.path.dirname(os.path.abspath(self.python_script_path.get())) if self.python_script_path.get() else ""
|
||||
|
||||
for item in parsed_datas:
|
||||
if isinstance(item, (tuple, list)) and len(item) == 2:
|
||||
source, destination = item
|
||||
abs_source = source
|
||||
# Convert relative source paths based on the script's directory
|
||||
if script_dir and isinstance(source, str) and not os.path.isabs(source):
|
||||
abs_source = os.path.abspath(os.path.join(script_dir, source))
|
||||
self._log_to_gui(f" Risolto percorso relativo data: '{source}' -> '{abs_source}'", level="DEBUG")
|
||||
|
||||
if isinstance(abs_source, str) and isinstance(destination, str):
|
||||
self.added_data_list.append((abs_source, destination))
|
||||
self.data_listbox.insert(tk.END, f"{abs_source} -> {destination}")
|
||||
else: self._log_to_gui(f" Ignorata voce 'datas' non valida (non stringhe): {item}", level="WARNING")
|
||||
else: self._log_to_gui(f" Ignorata voce 'datas' non valida (formato non tupla/lista di 2): {item}", level="WARNING")
|
||||
self._log_to_gui("Lista file aggiuntivi popolata dallo spec.", level="DEBUG")
|
||||
else: self._log_to_gui("Nessuna voce 'datas' trovata o valida nello spec.", level="DEBUG")
|
||||
|
||||
self._log_to_gui("Aggiornamento GUI dalle opzioni completato.", level="INFO")
|
||||
|
||||
def _select_icon(self):
|
||||
""" Handles icon file selection based on platform. """
|
||||
if sys.platform == "win32": filetypes, default_ext = config.ICON_FILE_TYPES_WINDOWS, config.ICON_DEFAULT_EXT_WINDOWS
|
||||
elif sys.platform == "darwin": filetypes, default_ext = config.ICON_FILE_TYPES_MACOS, config.ICON_DEFAULT_EXT_MACOS
|
||||
else: filetypes, default_ext = config.ICON_FILE_TYPES_LINUX, config.ICON_DEFAULT_EXT_LINUX
|
||||
|
||||
file_path = filedialog.askopenfilename(title="Seleziona Icona Applicazione", filetypes=filetypes, defaultextension=default_ext)
|
||||
if file_path: self.icon_path.set(file_path); self._log_to_gui(f"Selezionata icona: {file_path}")
|
||||
|
||||
def _clear_icon(self):
|
||||
""" Clears the icon selection. """
|
||||
self.icon_path.set("")
|
||||
self._log_to_gui("Selezione icona rimossa.")
|
||||
|
||||
# --- Methods for Added Data ---
|
||||
|
||||
def _add_data_file(self):
|
||||
""" Adds a selected file to the 'add_data' list. """
|
||||
source_path = filedialog.askopenfilename(title="Seleziona File Sorgente da Aggiungere")
|
||||
if not source_path: self._log_to_gui("Aggiunta file annullata.", level="DEBUG"); return
|
||||
abs_source_path = os.path.abspath(source_path)
|
||||
dest_path = simpledialog.askstring("Destinazione nel Bundle", f"Percorso destinazione relativo per:\n{os.path.basename(abs_source_path)}\n(es. '.', 'data', 'images/subdir')", initialvalue=".")
|
||||
if dest_path is None: self._log_to_gui("Aggiunta file annullata (destinazione).", level="DEBUG"); return
|
||||
entry = (abs_source_path, dest_path); self.added_data_list.append(entry)
|
||||
display_text = f"{abs_source_path} -> {dest_path}"; self.data_listbox.insert(tk.END, display_text)
|
||||
self._log_to_gui(f"Aggiunto file dati: {display_text}", level="INFO")
|
||||
|
||||
def _add_data_directory(self):
|
||||
""" Adds a selected directory to the 'add_data' list. """
|
||||
source_path = filedialog.askdirectory(title="Seleziona Cartella Sorgente da Aggiungere")
|
||||
if not source_path: self._log_to_gui("Aggiunta cartella annullata.", level="DEBUG"); return
|
||||
abs_source_path = os.path.abspath(source_path)
|
||||
default_dest = os.path.basename(abs_source_path)
|
||||
dest_path = simpledialog.askstring("Destinazione nel Bundle", f"Percorso destinazione relativo per cartella:\n{default_dest}\n(es. '{default_dest}', 'data/{default_dest}')", initialvalue=default_dest)
|
||||
if dest_path is None: self._log_to_gui("Aggiunta cartella annullata (destinazione).", level="DEBUG"); return
|
||||
entry = (abs_source_path, dest_path); self.added_data_list.append(entry)
|
||||
display_text = f"{abs_source_path} -> {dest_path}"; self.data_listbox.insert(tk.END, display_text)
|
||||
self._log_to_gui(f"Aggiunta cartella dati: {display_text}", level="INFO")
|
||||
|
||||
def _remove_selected_data(self):
|
||||
""" Removes the selected item from the 'add_data' list. """
|
||||
selected_indices = self.data_listbox.curselection()
|
||||
if not selected_indices: messagebox.showwarning("Nessuna Selezione", "Seleziona una voce da rimuovere."); return
|
||||
index = selected_indices[0]
|
||||
item_text = self.data_listbox.get(index); self.data_listbox.delete(index)
|
||||
if index < len(self.added_data_list): removed_entry = self.added_data_list.pop(index); self._log_to_gui(f"Rimossa voce dati: {item_text}", level="INFO")
|
||||
else: self._log_to_gui(f"Errore: Indice {index} fuori range per lista dati interna.", level="ERROR")
|
||||
|
||||
# --- Logging and Queue Handling ---
|
||||
|
||||
def _log_to_gui(self, message, level="INFO"):
|
||||
""" Appends a formatted message to the output log area safely. """
|
||||
formatted_message = f"[{level}] {str(message).strip()}\n"
|
||||
if threading.current_thread() is threading.main_thread(): self._update_output_log_widget(formatted_message)
|
||||
else: self.build_queue.put(("LOG", formatted_message))
|
||||
|
||||
def _update_output_log_widget(self, message):
|
||||
""" Internal method to append text to the ScrolledText widget. """
|
||||
try:
|
||||
self.output_text.config(state="normal"); self.output_text.insert(tk.END, message)
|
||||
self.output_text.see(tk.END); self.output_text.config(state="disabled")
|
||||
self.output_text.update_idletasks()
|
||||
except Exception as e: print(f"[ERROR] Errore aggiornamento log widget: {e}\n{traceback.format_exc()}")
|
||||
|
||||
def _check_build_queue(self):
|
||||
""" Periodically checks the build queue for messages from the builder thread. """
|
||||
try:
|
||||
while True:
|
||||
item = self.build_queue.get_nowait()
|
||||
if isinstance(item, tuple) and len(item) == 2:
|
||||
command, data = item
|
||||
if command == "LOG": self._update_output_log_widget(data)
|
||||
elif command == "BUILD_SUCCESS": self._log_to_gui(data, level="SUCCESS"); messagebox.showinfo("Build Completata", data); self._on_build_finished()
|
||||
elif command == "BUILD_ERROR": self._log_to_gui(data, level="ERROR"); messagebox.showerror("Build Fallita", data); self._on_build_finished()
|
||||
elif command == "BUILD_FINISHED": self._log_to_gui("Segnale completamento processo build ricevuto.", level="DEBUG")
|
||||
else: self._log_to_gui(f"Comando sconosciuto dalla coda: {command}", level="WARNING")
|
||||
else: self._log_to_gui(f"Elemento inatteso nella coda: {item}", level="WARNING")
|
||||
self.build_queue.task_done()
|
||||
except queue.Empty: pass
|
||||
except Exception as e: self._log_to_gui(f"Errore processamento coda build: {e}\n{traceback.format_exc()}", level="CRITICAL")
|
||||
finally: self.after(100, self._check_build_queue)
|
||||
|
||||
def _on_build_finished(self):
|
||||
""" Actions to take when build completes (success or failure). """
|
||||
self.build_button.config(state="normal")
|
||||
self.build_thread = None
|
||||
self._log_to_gui("Processo di build terminato. Controlli GUI riabilitati.", level="INFO")
|
||||
|
||||
# --- Spec File Generation ---
|
||||
def _generate_spec_content(self):
|
||||
""" Generates the .spec file content including 'datas' from the GUI list. """
|
||||
script_path = self.python_script_path.get()
|
||||
if not script_path or not os.path.exists(script_path): messagebox.showerror("Errore", "Percorso script Python invalido."); return None
|
||||
self._log_to_gui("Generazione contenuto .spec dalle opzioni GUI...", level="INFO")
|
||||
script_abspath = os.path.abspath(script_path); script_dir = os.path.dirname(script_abspath)
|
||||
app_name = self.app_name.get() or os.path.splitext(os.path.basename(script_abspath))[0]
|
||||
icon_path = self.icon_path.get()
|
||||
icon_abspath = os.path.abspath(icon_path) if icon_path and os.path.exists(icon_path) else None
|
||||
def escape_path(p): return p.replace("\\", "\\\\") if p else ""
|
||||
|
||||
# --- Analysis Section ---
|
||||
analysis_scripts = f"['{escape_path(script_abspath)}']"
|
||||
analysis_pathex = f"['{escape_path(script_dir)}']"
|
||||
# Use self.added_data_list to generate the 'datas' argument
|
||||
formatted_datas = "[" + ",\n ".join([f"('{escape_path(src)}', '{escape_path(dst)}')" for src, dst in self.added_data_list]) + "]"
|
||||
self._log_to_gui(f"Formattati {len(self.added_data_list)} voci 'datas' per lo spec.", level="DEBUG")
|
||||
formatted_hiddenimports = "[]"; formatted_binaries = "[]" # Placeholders for now
|
||||
|
||||
analysis_str = f"""a = Analysis(scripts={analysis_scripts}, pathex={analysis_pathex}, binaries={formatted_binaries}, datas={formatted_datas}, hiddenimports={formatted_hiddenimports}, hookspath=[], runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=None, noarchive=False,)"""
|
||||
pyz_str = "pyz = PYZ(a.pure, a.zipped_data, cipher=None)"
|
||||
exe_options = [f"name='{app_name}'", "debug=False", "bootloader_ignore_signals=False", "strip=False", f"upx={config.DEFAULT_SPEC_OPTIONS['use_upx']}", "upx_exclude=[]", "runtime_tmpdir=None", f"console={not self.is_windowed.get()}"]
|
||||
if icon_abspath: exe_options.append(f"icon='{escape_path(icon_abspath)}'")
|
||||
exe_options_str = ",\n ".join(exe_options)
|
||||
exe_str = f"""exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], {exe_options_str},)"""
|
||||
collect_str = ""
|
||||
if not self.is_onefile.get(): collect_str = f"""coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, strip=False, upx={config.DEFAULT_SPEC_OPTIONS['use_upx']}, upx_exclude=[], name='{app_name}',)"""
|
||||
|
||||
spec_header = "# -*- mode: python ; coding: utf-8 -*-\n\nblock_cipher = None\n"
|
||||
spec_content = f"{spec_header}\n{analysis_str}\n\n{pyz_str}\n\n{exe_str}\n{collect_str}"
|
||||
self._log_to_gui("Contenuto file .spec generato.", level="DEBUG")
|
||||
return spec_content.strip() + "\n"
|
||||
|
||||
# --- Save Spec File ---
|
||||
def _save_spec_file(self, content):
|
||||
""" Saves the generated content to the .spec file path. """
|
||||
spec_path = self.spec_file_path.get()
|
||||
if not spec_path: messagebox.showerror("Errore", "Impossibile salvare spec: percorso non impostato."); return False
|
||||
self._log_to_gui(f"Tentativo salvataggio spec file su: {spec_path}", level="INFO")
|
||||
try:
|
||||
os.makedirs(os.path.dirname(spec_path), exist_ok=True)
|
||||
with open(spec_path, 'w', encoding='utf-8') as f: f.write(content)
|
||||
self._log_to_gui(f"File spec salvato con successo: {spec_path}", level="INFO"); return True
|
||||
except Exception as e:
|
||||
error_msg = f"Fallito salvataggio spec file '{spec_path}': {e}\n{traceback.format_exc()}"
|
||||
self._log_to_gui(error_msg, level="ERROR"); messagebox.showerror("Errore Salvataggio File", error_msg); return False
|
||||
|
||||
# --- Build Execution ---
|
||||
def _start_build(self):
|
||||
""" Validates, generates/saves spec, starts build thread using the 'pyinstaller' command
|
||||
with a potentially cleaned environment for Tcl/Tk variables. """
|
||||
self._log_to_gui("="*20 + " PROCESSO DI BUILD INIZIATO " + "="*20, level="INFO")
|
||||
|
||||
script_path = self.python_script_path.get(); spec_path = self.spec_file_path.get()
|
||||
if not script_path or not os.path.exists(script_path): messagebox.showerror("Errore", "Seleziona uno script Python valido."); self._log_to_gui("Build annullata: script non valido.", level="ERROR"); return
|
||||
if not spec_path: messagebox.showerror("Errore", "Errore interno: percorso spec non impostato."); self._log_to_gui("Build annullata: percorso spec non impostato.", level="ERROR"); return
|
||||
|
||||
self._log_to_gui("Generazione contenuto spec dalle opzioni GUI...", level="INFO")
|
||||
spec_content = self._generate_spec_content();
|
||||
if spec_content is None: self._log_to_gui("Build annullata: fallita generazione spec.", level="ERROR"); return
|
||||
|
||||
self._log_to_gui("Salvataggio file spec...", level="INFO")
|
||||
if not self._save_spec_file(spec_content): self._log_to_gui("Build annullata: fallito salvataggio spec.", level="ERROR"); return
|
||||
|
||||
self._log_to_gui("Preparazione esecuzione PyInstaller...", level="INFO")
|
||||
self.build_button.config(state="disabled")
|
||||
script_dir = os.path.dirname(os.path.abspath(script_path))
|
||||
dist_path_abs = os.path.join(script_dir, config.DEFAULT_SPEC_OPTIONS['output_dir_name'])
|
||||
work_path_abs = os.path.join(script_dir, config.DEFAULT_SPEC_OPTIONS['work_dir_name'])
|
||||
|
||||
pyinstaller_cmd = shutil.which("pyinstaller")
|
||||
if not pyinstaller_cmd:
|
||||
error_msg = ("Comando 'pyinstaller' non trovato nel PATH...\n"
|
||||
"Assicurati sia installato e nel PATH.")
|
||||
self._log_to_gui(error_msg, level="CRITICAL"); messagebox.showerror("Errore PyInstaller", error_msg)
|
||||
self.build_button.config(state="normal"); return
|
||||
|
||||
self._log_to_gui(f"Trovato eseguibile PyInstaller in: {pyinstaller_cmd}", level="DEBUG")
|
||||
command = [pyinstaller_cmd, spec_path, "--distpath", dist_path_abs, "--workpath", work_path_abs, "--log-level", self.log_level.get()]
|
||||
if config.DEFAULT_SPEC_OPTIONS['clean_build']: command.append("--clean")
|
||||
if not config.DEFAULT_SPEC_OPTIONS['confirm_overwrite']: command.append("--noconfirm")
|
||||
|
||||
build_env = os.environ.copy()
|
||||
removed_vars = []
|
||||
if 'TCL_LIBRARY' in build_env: del build_env['TCL_LIBRARY']; removed_vars.append('TCL_LIBRARY')
|
||||
if 'TK_LIBRARY' in build_env: del build_env['TK_LIBRARY']; removed_vars.append('TK_LIBRARY')
|
||||
if removed_vars: self._log_to_gui(f"Rimosse variabili d'ambiente {removed_vars} per PyInstaller esterno.", level="DEBUG")
|
||||
else: self._log_to_gui("Nessuna variabile Tcl/Tk ereditata trovata da rimuovere.", level="DEBUG")
|
||||
|
||||
quoted_command = [f'"{arg}"' if ' ' in arg else arg for arg in command]
|
||||
self._log_to_gui(f"Comando base: {' '.join(quoted_command)}", level="DEBUG")
|
||||
self._log_to_gui(f"Directory di lavoro: {script_dir}", level="DEBUG")
|
||||
|
||||
self.build_thread = threading.Thread(
|
||||
target=builder.run_build_in_thread,
|
||||
args=(command, script_dir, self.build_queue, self._log_to_gui, config.DEFAULT_SPEC_OPTIONS['output_dir_name'], build_env), # Passa ambiente
|
||||
daemon=True
|
||||
)
|
||||
self.build_thread.start()
|
||||
self._log_to_gui("Thread di build avviato con ambiente personalizzato.", level="INFO")
|
||||
|
||||
# --- End of PyInstallerGUI class ---
|
||||
|
||||
# Note: The main execution block remains in CreateExecFromPy.py
|
||||
# if __name__ == "__main__":
|
||||
# app = PyInstallerGUI()
|
||||
# app.mainloop()
|
||||
63
pyinstallerguiwrapper.spec
Normal file
63
pyinstallerguiwrapper.spec
Normal file
@ -0,0 +1,63 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
# This spec file is for building the PyInstaller GUI Wrapper tool itself.
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
# MODIFIED: Point to the new entry point within the subpackage
|
||||
scripts=['pyinstallerguiwrapper/__main__.py'],
|
||||
# MODIFIED: Include the root directory '.' so PyInstaller finds the subpackage 'pyinstallerguiwrapper'
|
||||
pathex=['.'],
|
||||
binaries=[],
|
||||
datas=[], # No data files needed for the tool itself currently
|
||||
hiddenimports=[], # Add any modules PyInstaller might miss (e.g., sometimes needed for pkg_resources)
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False
|
||||
)
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
# MODIFIED: Set the application name
|
||||
name='PyInstallerGUIWrapper',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True, # Use UPX if available (optional)
|
||||
runtime_tmpdir=None,
|
||||
# MODIFIED: Set console=False for a GUI application
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
# MODIFIED: Add icon if pyinstallerguiwrapper.ico exists in the root
|
||||
icon='pyinstallerguiwrapper.ico' # Assumes the icon file is in the same directory as the spec file
|
||||
)
|
||||
|
||||
# Use COLLECT for one-dir builds (recommended for GUI stability)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=True, # Match UPX setting
|
||||
upx_exclude=[],
|
||||
# MODIFIED: Set the output directory name
|
||||
name='PyInstallerGUIWrapper' # Name of the folder in _dist/
|
||||
)
|
||||
|
||||
# If you wanted a one-file build instead (can sometimes be less stable for Tkinter):
|
||||
# Remove the 'coll = COLLECT(...)' block above
|
||||
# And potentially adjust EXE options (though Analysis usually handles datas/binaries correctly now)
|
||||
@ -8,12 +8,12 @@ Run this file to launch the application.
|
||||
|
||||
import sys
|
||||
import os
|
||||
import traceback # Import traceback for better error reporting
|
||||
import traceback # Import traceback for better error reporting
|
||||
|
||||
# --- Crucial: Add the directory containing the modules to sys.path ---
|
||||
# This allows Python to find 'gui.py', 'config.py', etc. when run directly.
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
print(f"Adding to sys.path: {script_dir}") # Debug print
|
||||
print(f"Adding to sys.path: {script_dir}") # Debug print
|
||||
if script_dir not in sys.path:
|
||||
sys.path.insert(0, script_dir)
|
||||
|
||||
@ -22,6 +22,7 @@ if script_dir not in sys.path:
|
||||
try:
|
||||
# Import the main GUI class directly by name
|
||||
from gui import PyInstallerGUI
|
||||
|
||||
# We might need other modules here later if needed at top level
|
||||
# import config
|
||||
# import builder
|
||||
@ -30,7 +31,7 @@ except ImportError as e:
|
||||
print(f"ERROR: Could not import required modules.")
|
||||
print(f"ImportError: {e}")
|
||||
print(f"Current sys.path: {sys.path}")
|
||||
traceback.print_exc() # Print detailed traceback
|
||||
traceback.print_exc() # Print detailed traceback
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"ERROR: An unexpected error occurred during initial imports: {e}")
|
||||
@ -46,22 +47,29 @@ def main():
|
||||
app = PyInstallerGUI()
|
||||
app.mainloop()
|
||||
except Exception as e:
|
||||
print(f"FATAL ERROR: An unexpected error occurred during application execution: {e}")
|
||||
print(
|
||||
f"FATAL ERROR: An unexpected error occurred during application execution: {e}"
|
||||
)
|
||||
traceback.print_exc()
|
||||
try:
|
||||
# Attempt to show a Tkinter error box if possible
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
messagebox.showerror("Fatal Error", f"Application failed to run:\n{e}\n\nSee console for details.")
|
||||
messagebox.showerror(
|
||||
"Fatal Error",
|
||||
f"Application failed to run:\n{e}\n\nSee console for details.",
|
||||
)
|
||||
root.destroy()
|
||||
except Exception:
|
||||
pass # Ignore if Tkinter fails here too
|
||||
pass # Ignore if Tkinter fails here too
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# --- Main Execution Guard ---
|
||||
if __name__ == "__main__":
|
||||
# Check Python version if needed (Tkinter themes benefit from newer versions)
|
||||
# print(f"Running with Python {sys.version}")
|
||||
main()
|
||||
main()
|
||||
2
pyinstallerguiwrapper/__init__.py
Normal file
2
pyinstallerguiwrapper/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Intentionally left blank to indicate this directory is a Python package.
|
||||
68
pyinstallerguiwrapper/__main__.py
Normal file
68
pyinstallerguiwrapper/__main__.py
Normal file
@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Main entry point script for the PyInstaller GUI Wrapper application when run as a module.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import traceback # Import traceback for better error reporting
|
||||
|
||||
# --- No sys.path manipulation needed when running as a module ---
|
||||
# Python handles the package structure automatically.
|
||||
|
||||
# --- Import the necessary modules ---
|
||||
try:
|
||||
# MODIFIED: Use relative import to get the GUI class from within the same package
|
||||
from .gui import PyInstallerGUI
|
||||
except ImportError as e:
|
||||
# Provide more context in case of import errors
|
||||
package_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
print(f"ERROR: Could not import required modules from package.")
|
||||
print(f" Attempted relative import within: {package_dir}")
|
||||
print(f"ImportError: {e}")
|
||||
print(f"Current sys.path: {sys.path}")
|
||||
traceback.print_exc() # Print detailed traceback
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"ERROR: An unexpected error occurred during initial imports: {e}")
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Initializes and runs the PyInstaller GUI application.
|
||||
"""
|
||||
try:
|
||||
app = PyInstallerGUI()
|
||||
app.mainloop()
|
||||
except Exception as e:
|
||||
# MODIFIED: Error message context updated
|
||||
print(
|
||||
f"FATAL ERROR: An unexpected error occurred during application execution: {e}"
|
||||
)
|
||||
traceback.print_exc()
|
||||
try:
|
||||
# Attempt to show a Tkinter error box if possible
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
# MODIFIED: Messagebox content translated/updated
|
||||
messagebox.showerror(
|
||||
"Fatal Error",
|
||||
f"Application failed to run:\n{e}\n\nSee console for details.",
|
||||
)
|
||||
root.destroy()
|
||||
except Exception:
|
||||
pass # Ignore if Tkinter fails here too
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# --- Main Execution Guard ---
|
||||
# This guard is still useful if you were to potentially import `__main__` elsewhere,
|
||||
# though it's less critical when the primary execution path is `python -m`.
|
||||
if __name__ == "__main__":
|
||||
# print(f"Running PyInstallerGUIWrapper via __main__.py with Python {sys.version}")
|
||||
main()
|
||||
67
pyinstallerguiwrapper/builder.py
Normal file
67
pyinstallerguiwrapper/builder.py
Normal file
@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" builder.py - Handles the execution of the PyInstaller build process in a separate thread. """
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import queue
|
||||
import os
|
||||
import traceback # For logging exceptions
|
||||
|
||||
# NO CHANGES NEEDED in this file for the restructuring
|
||||
|
||||
def run_build_in_thread(command, working_dir, output_queue, logger_func, output_dir_name, environment=None):
|
||||
"""
|
||||
Executes the PyInstaller command using subprocess.Popen in the background.
|
||||
... (docstring remains the same) ...
|
||||
"""
|
||||
# ... (Function body remains the same as the previously translated version) ...
|
||||
build_process = None
|
||||
try:
|
||||
logger_func("Build thread starting execution...", level="INFO")
|
||||
env_log_msg = f"Using {'custom' if environment else 'inherited'} environment."
|
||||
if environment: pass
|
||||
logger_func(f"PyInstaller process starting. {env_log_msg}", level="INFO")
|
||||
build_process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=working_dir,
|
||||
text=True, encoding='utf-8', errors='replace', bufsize=1, env=environment )
|
||||
logger_func(f"PyInstaller process launched (PID: {build_process.pid}).", level="INFO")
|
||||
while True:
|
||||
try:
|
||||
line = build_process.stdout.readline()
|
||||
if not line:
|
||||
if build_process.poll() is not None:
|
||||
logger_func("End of PyInstaller output stream.", level="DEBUG")
|
||||
break
|
||||
else: pass
|
||||
else: output_queue.put(("LOG", line))
|
||||
except Exception as read_err:
|
||||
logger_func(f"Error reading PyInstaller output stream: {read_err}\n{traceback.format_exc()}", level="ERROR")
|
||||
break
|
||||
return_code = build_process.wait()
|
||||
logger_func(f"PyInstaller process finished with exit code: {return_code}", level="INFO")
|
||||
if return_code == 0:
|
||||
dist_path_abs = os.path.join(working_dir, output_dir_name)
|
||||
if os.path.exists(dist_path_abs):
|
||||
success_msg = f"Build completed successfully!\nOutput Directory: {dist_path_abs}"
|
||||
output_queue.put(("BUILD_SUCCESS", success_msg))
|
||||
else:
|
||||
warn_msg = (f"Build finished with exit code 0, but output directory not found:\n"
|
||||
f"{dist_path_abs}\nCheck the full log for potential issues.")
|
||||
logger_func(warn_msg, level="WARNING")
|
||||
output_queue.put(("BUILD_ERROR", warn_msg))
|
||||
else:
|
||||
error_msg = f"Build failed! (Exit Code: {return_code})\nCheck the log for details."
|
||||
output_queue.put(("BUILD_ERROR", error_msg))
|
||||
except FileNotFoundError:
|
||||
error_msg = ("Error: 'pyinstaller' command not found.\n"
|
||||
"Ensure PyInstaller is installed correctly and in the system PATH.")
|
||||
output_queue.put(("BUILD_ERROR", error_msg))
|
||||
logger_func(error_msg, level="CRITICAL")
|
||||
except Exception as e:
|
||||
error_msg = f"Unexpected error during build process execution: {e}"
|
||||
output_queue.put(("BUILD_ERROR", error_msg))
|
||||
logger_func(error_msg, level="CRITICAL")
|
||||
logger_func(traceback.format_exc(), level="DEBUG")
|
||||
finally:
|
||||
output_queue.put(("BUILD_FINISHED", None))
|
||||
logger_func("Build thread finished.", level="INFO")
|
||||
57
pyinstallerguiwrapper/config.py
Normal file
57
pyinstallerguiwrapper/config.py
Normal file
@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Configuration constants for the PyInstaller GUI Wrapper application.
|
||||
"""
|
||||
|
||||
# NO CHANGES NEEDED in this file for the restructuring
|
||||
|
||||
# Default values for PyInstaller options managed by the GUI
|
||||
DEFAULT_SPEC_OPTIONS = {
|
||||
'onefile': False,
|
||||
'windowed': True, # Corresponds to --windowed/--console (--noconsole)
|
||||
'name': '',
|
||||
'icon': '',
|
||||
'add_data': [],
|
||||
'hidden_imports': [],
|
||||
'log_level': 'INFO',
|
||||
'clean_build': True, # Corresponds to --clean
|
||||
'confirm_overwrite': False,# Corresponds to --noconfirm (True means --noconfirm is active)
|
||||
'output_dir_name': '_dist', # Default name for the output directory
|
||||
'work_dir_name': '_build', # Default name for the temporary build directory
|
||||
'use_upx': True,
|
||||
# Internal tracking - Populated by parser, used by GUI/generator
|
||||
'scripts': [],
|
||||
'pathex': [],
|
||||
'binaries': [],
|
||||
'datas': [], # Changed from 'add_data' to match PyInstaller terminology
|
||||
}
|
||||
|
||||
# Log levels available in PyInstaller
|
||||
PYINSTALLER_LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"]
|
||||
|
||||
# File type filters (Descriptions are already English)
|
||||
SCRIPT_FILE_TYPES = [
|
||||
("Python files", "*.py"),
|
||||
("Pythonw files", "*.pyw"),
|
||||
("All files", "*.*")
|
||||
]
|
||||
|
||||
ICON_FILE_TYPES_WINDOWS = [
|
||||
("Icon files", "*.ico"),
|
||||
("All files", "*.*")
|
||||
]
|
||||
ICON_DEFAULT_EXT_WINDOWS = ".ico"
|
||||
|
||||
ICON_FILE_TYPES_MACOS = [
|
||||
("Apple Icon files", "*.icns"),
|
||||
("PNG images", "*.png"),
|
||||
("All files", "*.*")
|
||||
]
|
||||
ICON_DEFAULT_EXT_MACOS = ".icns"
|
||||
|
||||
ICON_FILE_TYPES_LINUX = [
|
||||
("Image files", "*.png"),
|
||||
("Icon files", "*.ico"),
|
||||
("All files", "*.*")
|
||||
]
|
||||
ICON_DEFAULT_EXT_LINUX = ".png"
|
||||
1031
pyinstallerguiwrapper/gui.py
Normal file
1031
pyinstallerguiwrapper/gui.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,210 +7,152 @@ 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.
|
||||
Reduces logging noise by being more selective about evaluation attempts.
|
||||
"""
|
||||
def __init__(self, logger_func=print):
|
||||
"""
|
||||
Initialize the visitor.
|
||||
|
||||
Args:
|
||||
logger_func (callable): Function for logging (takes message, level).
|
||||
"""
|
||||
self.parsed_options = {}
|
||||
self.logger = logger_func
|
||||
self._variable_assignments = {}
|
||||
|
||||
def _log(self, message, level="DEBUG"):
|
||||
""" Helper to call the logger function. """
|
||||
try:
|
||||
self.logger(message, level=level)
|
||||
except TypeError:
|
||||
self.logger(f"[{level}] {message}")
|
||||
|
||||
def _evaluate_node(self, node):
|
||||
"""
|
||||
Attempts to evaluate the value of an AST node more quietly.
|
||||
Handles constants, simple lists/tuples/dicts, and tracked variables.
|
||||
Returns evaluated Python value, or None if evaluation is complex/unsafe.
|
||||
Logs warnings ONLY for unresolved variables or errors within structures.
|
||||
"""
|
||||
# --- Handle Simple Constants ---
|
||||
if isinstance(node, ast.Constant): # Python 3.8+
|
||||
return node.value
|
||||
elif isinstance(node, (ast.Str, ast.Num, ast.NameConstant)): # Python < 3.8 compat
|
||||
# Handle deprecated nodes
|
||||
# ... (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 # For True, False, None
|
||||
|
||||
# --- Handle Simple Lists/Tuples ---
|
||||
return node.value
|
||||
elif isinstance(node, (ast.List, ast.Tuple)):
|
||||
try:
|
||||
elements = [self._evaluate_node(el) for el in node.elts]
|
||||
# Check if ANY element failed evaluation (returned None)
|
||||
if any(el is None for el in elements):
|
||||
# Log warning here, as we expected to evaluate the elements
|
||||
self._log(f"Could not evaluate all elements in list/tuple: {ast.dump(node)}", level="WARNING")
|
||||
return None # Cannot reliably evaluate the whole structure
|
||||
# If all elements evaluated, return the list/tuple
|
||||
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
|
||||
|
||||
# --- Handle Simple Dictionaries ---
|
||||
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]
|
||||
# Check if ANY key or value failed evaluation
|
||||
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
|
||||
# If all evaluated, return the dict
|
||||
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
|
||||
|
||||
# --- Handle Variable Names ---
|
||||
elif isinstance(node, ast.Name):
|
||||
if node.id in self._variable_assignments:
|
||||
return self._variable_assignments[node.id]
|
||||
else:
|
||||
# Log warning here, as we failed to resolve a name we encountered
|
||||
self._log(f"Cannot resolve variable name during evaluation: {node.id}", level="WARNING")
|
||||
return None
|
||||
|
||||
# --- Handle Other Node Types Silently ---
|
||||
else:
|
||||
# For types like ast.Call, ast.Attribute, ast.BinOp, etc.,
|
||||
# we assume they are too complex to evaluate safely or are not
|
||||
# simple constants we care about. Return None without logging a warning.
|
||||
# self._log(f"Skipping evaluation for node type: {type(node)}", level="DEBUG") # Optional debug log
|
||||
return None
|
||||
return None # Skip complex nodes quietly
|
||||
|
||||
def visit_Assign(self, node):
|
||||
"""
|
||||
Visits assignment statements. Tries to track assignments only if the
|
||||
value appears to be a simple constant or structure. Avoids warnings
|
||||
for assignments like 'a = Analysis(...)'.
|
||||
"""
|
||||
# Check if assignment is to a single variable name
|
||||
# ... (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
|
||||
|
||||
# --- Check if the value is likely evaluatable BEFORE calling _evaluate_node ---
|
||||
# Only proceed if it looks like a constant, simple structure, or known variable name
|
||||
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:
|
||||
# Successfully evaluated, track the assignment
|
||||
self._variable_assignments[var_name] = value
|
||||
self._log(f"Tracked assignment: {var_name} = {repr(value)}")
|
||||
else:
|
||||
# Evaluation attempted but failed (e.g., unresolved name inside list)
|
||||
# Log a warning because we expected to evaluate this type
|
||||
self._log(f"Could not evaluate value for assignment: {var_name} = {ast.dump(node.value)}", level="WARNING")
|
||||
# else:
|
||||
# If node.value is e.g. an ast.Call like Analysis(), PYZ(), EXE(),
|
||||
# do nothing - don't try to evaluate, don't log warning.
|
||||
# self._log(f"Skipping tracking for complex assignment: {var_name}", level="DEBUG") # Optional debug log
|
||||
|
||||
# Continue traversal regardless of whether we tracked the assignment
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def visit_Call(self, node):
|
||||
""" Visits function call nodes (e.g., Analysis(...), EXE(...)). """
|
||||
# ... (Function body remains the same as previous version) ...
|
||||
if isinstance(node.func, ast.Name):
|
||||
call_name = node.func.id
|
||||
options = {} # Extracted args for this call
|
||||
|
||||
# --- Parse keyword arguments ---
|
||||
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:
|
||||
# Log evaluation failures for keyword args as WARNING,
|
||||
# as these are more likely intended to be simple values.
|
||||
self._log(f"Could not evaluate keyword argument '{arg_name}' for call '{call_name}'. Value: {ast.dump(keyword.value)}", level="WARNING")
|
||||
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")
|
||||
|
||||
# --- Parse positional arguments ---
|
||||
# We evaluate them mainly to potentially populate fields like 'scripts'
|
||||
# if they aren't provided as keywords. Failure to evaluate these
|
||||
# (e.g., 'pyz' in EXE(pyz, ...)) is less critical, maybe log as DEBUG.
|
||||
positional_args = []
|
||||
for i, arg_node in enumerate(node.args):
|
||||
arg_value = self._evaluate_node(arg_node)
|
||||
positional_args.append(arg_value)
|
||||
# Optional: Log failure for positional args at DEBUG level
|
||||
# if arg_value is None:
|
||||
# self._log(f"Could not evaluate positional argument {i} for call '{call_name}'. Value: {ast.dump(arg_node)}", level="DEBUG")
|
||||
|
||||
|
||||
# --- Process based on function name ---
|
||||
# (Logic for Analysis, EXE, COLLECT remains the same)
|
||||
if call_name == 'Analysis':
|
||||
self._log(f"Found Analysis() call.")
|
||||
# Prioritize keyword args, fallback to positional
|
||||
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', []) # Default to empty list
|
||||
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 [] # Use empty list if None
|
||||
|
||||
self.parsed_options['pathex'] = positional_args[1] or []
|
||||
self.parsed_options['datas'] = options.get('datas', [])
|
||||
self.parsed_options['hiddenimports'] = options.get('hiddenimports', [])
|
||||
self.parsed_options['binaries'] = options.get('binaries', [])
|
||||
|
||||
elif call_name == 'EXE':
|
||||
self._log(f"Found EXE() call.")
|
||||
self.parsed_options['name'] = options.get('name')
|
||||
self.parsed_options['icon'] = options.get('icon')
|
||||
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
|
||||
# If 'console' is None (not present or unevaluatable), 'windowed' remains unset here
|
||||
|
||||
# Assume onefile=True initially
|
||||
self.parsed_options['onefile'] = True
|
||||
|
||||
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.")
|
||||
# COLLECT overrides the onefile assumption
|
||||
self.parsed_options['onefile'] = False
|
||||
|
||||
# Continue traversal
|
||||
self.parsed_options['onefile'] = False # COLLECT means it's not onefile
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def get_options(self):
|
||||
""" Returns the dictionary of parsed options. Normalize paths if needed. """
|
||||
# Normalize icon path if found
|
||||
# ... (Function body remains the same as previous version) ...
|
||||
if 'icon' in self.parsed_options and self.parsed_options['icon']:
|
||||
try:
|
||||
self.parsed_options['icon'] = os.path.normpath(self.parsed_options['icon'])
|
||||
# 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
|
||||
|
||||
# --- The `parse_spec_file` function remains the same ---
|
||||
|
||||
def parse_spec_file(spec_path, logger_func=print):
|
||||
""" Safely parses a .spec file using AST. """
|
||||
# (No changes needed in this function itself)
|
||||
# ... (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) # Use updated Visitor
|
||||
visitor = _SpecVisitor(logger_func=logger_func)
|
||||
visitor.visit(tree)
|
||||
parsed_options = visitor.get_options()
|
||||
if not parsed_options:
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"display_name": "PyInstaller GUI",
|
||||
"description": "Crea eseguibili da script Python usando PyInstaller con una GUI.",
|
||||
"command": ["python", "CreateExecFromPy.py"],
|
||||
"version": "1.0",
|
||||
"display_name": "PyInstaller GUI Wrapper",
|
||||
"description": "Creates executables from Python projects using PyInstaller with a GUI.",
|
||||
"command": ["python", "-m", "pyinstallerguiwrapper"],
|
||||
"version": "1.1",
|
||||
"parameters": [],
|
||||
"has_gui": true
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user